diff --git a/README.md b/README.md index 61a6cd68..8d33044f 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,18 @@ https://github.com/user-attachments/assets/64c41f01-dffe-4318-bce4-16eec8de356e view_mode = "list", -- "list" or "tree" for files under commits }, + -- Pending comments editor + comments = { + ui = { + width = 72, -- Floating editor width (columns) + height = 6, -- Floating editor height (lines) + opacity = 0, -- Window blend (0 = opaque, 100 = fully transparent) + editor_mode = "insert", -- "insert" = start in insert mode, "normal" = start in normal mode + submit_keys = { "" }, -- Submit comment (normal mode) + cancel_keys = { "q" }, -- Close editor without saving (normal mode) + }, + }, + -- Keymaps in diff view keymaps = { view = { @@ -157,6 +169,12 @@ https://github.com/user-attachments/assets/64c41f01-dffe-4318-bce4-16eec8de356e show_help = "g?", -- Show floating window with available keymaps align_move = "gm", -- Temporarily align moved code blocks across panes toggle_layout = "t", -- Toggle between side-by-side and inline layout + comment_add = "ca", -- Open pending comment editor at cursor + comment_edit = "ce", -- Edit pending comment at cursor + comment_remove = "cd", -- Remove pending comment at cursor + comment_list = "cl", -- List all pending comments + comment_submit = "cs", -- Submit pending comments and clear UI + comment_clear = "cc", -- Clear pending comments without submitting }, explorer = { select = "", -- Open diff for selected file @@ -328,6 +346,46 @@ Show only changes introduced since branching from a base branch—exactly like a This uses `git merge-base` semantics (equivalent to `git diff main...HEAD`), showing only the changes introduced on your branch, not changes that happened on the base branch since you branched. +### Pending Comments + +You can attach lightweight pending comments in active CodeDiff diff panes: + +```vim +" Add a comment at cursor (opens multiline editor) +:CodeDiff comments add + +" Add a comment directly +:CodeDiff comments add Rename this helper before merge + +" Add a ranged comment (visual selection) +:'<,'>CodeDiff comments add +:'<,'>CodeDiff comments add Refactor this block + +" Edit comment at cursor +:CodeDiff comments edit + +" Edit comment by id +:CodeDiff comments edit 3 + +" Remove comment at cursor (for quick typo fixes) +:CodeDiff comments remove + +" Remove comment by id (shown as [cN] in the diff view) +:CodeDiff comments remove 3 + +" Show all pending comments in quickfix (jump with ) +:CodeDiff comments list + +" Submit all comments (sends to hook if set, otherwise copies to clipboard) +" After submit, comments are cleared from the UI +:CodeDiff comments submit + +" Clear pending comments without submitting +:CodeDiff comments clear +``` + +See `:help codediff-comments` for editor behavior, submit hooks, and transport details. + ### Git Diff Mode Compare the current buffer with a git revision: diff --git a/doc/codediff.txt b/doc/codediff.txt index a01055ea..5978486c 100644 --- a/doc/codediff.txt +++ b/doc/codediff.txt @@ -216,6 +216,16 @@ Setup entry point: conflicts = true, }, }, + comments = { + ui = { + width = 72, + height = 6, + opacity = 0, -- window blend (0 = opaque, 100 = fully transparent) + editor_mode = "insert", -- or "normal" to start in normal mode + submit_keys = { "" }, -- submit (normal mode) + cancel_keys = { "q" }, -- close editor (normal mode) + }, + }, keymaps = { view = { quit = "q", @@ -234,6 +244,11 @@ Setup entry point: show_help = "g?", align_move = "gm", toggle_layout = "t", + comment_add = "ca", + comment_edit = "ce", + comment_remove = "cd", + comment_submit = "cs", + comment_clear = "cC", }, explorer = { select = "", diff --git a/lua/codediff/commands.lua b/lua/codediff/commands.lua index f3c56343..95276ec9 100644 --- a/lua/codediff/commands.lua +++ b/lua/codediff/commands.lua @@ -2,7 +2,7 @@ local M = {} -- Subcommands available for :CodeDiff -M.SUBCOMMANDS = { "merge", "file", "dir", "history", "install" } +M.SUBCOMMANDS = { "merge", "file", "dir", "history", "comments", "install" } local git = require("codediff.core.git") local lifecycle = require("codediff.ui.lifecycle") @@ -517,6 +517,107 @@ local function handle_git_diff_merge_base(base_rev, target_rev, global_opts) end) end +local function parse_comment_id(raw) + local id = tonumber(raw) + if not id then + return nil + end + + if id < 1 or id ~= math.floor(id) then + return nil + end + + return id +end + +local function handle_comments(args, opts) + local comments = require("codediff.ui.comments") + local action = args[1] + + -- exit early + if not action or action == "" then + return + end + + if action == "add" then + local range_line1, range_line2 + if opts and opts.range == 2 then + range_line1 = opts.line1 + range_line2 = opts.line2 + end + + local text = table.concat(vim.list_slice(args, 2), " ") + if vim.trim(text) ~= "" then + comments.add_comment(text, range_line1, range_line2) + else + comments.open_add_editor({ range_line1 = range_line1, range_line2 = range_line2 }) + end + return + end + + if action == "edit" then + local rest = vim.list_slice(args, 2) + if #rest == 0 then + comments.open_edit_editor() + return + end + + local id = parse_comment_id(rest[1]) + if id then + if #rest == 1 then + comments.open_edit_editor(id) + else + comments.edit_comment(id, table.concat(rest, " ", 2)) + end + return + end + + comments.edit_comment(nil, table.concat(rest, " ")) + return + end + + if action == "remove" then + local rest = vim.list_slice(args, 2) + if #rest > 1 then + vim.notify("Usage: :CodeDiff comments remove [comment_id]", vim.log.levels.ERROR) + return + end + + if #rest == 1 then + local id = parse_comment_id(rest[1]) + if not id then + vim.notify("Usage: :CodeDiff comments remove [comment_id]", vim.log.levels.ERROR) + return + end + comments.remove_comment(id) + return + end + + comments.remove_comment() + return + end + + if action == "list" then + if #args > 1 then + vim.notify("Usage: :CodeDiff comments list", vim.log.levels.ERROR) + return + end + + comments.list_comments() + return + end + + if action == "submit" then + comments.submit_comments() + return + end + + if action == "clear" then + comments.clear_comments() + return + end +end + function M.vscode_merge(opts) local args = opts.fargs if #args == 0 then @@ -604,9 +705,27 @@ function M.vscode_merge(opts) end function M.vscode_diff(opts) - -- Check if current tab is a diff view and toggle (close) it if so + -- Pre-parse global flags -> strip them so subcommand + -- dispatch sees clean args + local global_opts = {} + local args = {} + for _, arg in ipairs(opts.fargs) do + if arg == "--inline" then + global_opts.layout = "inline" + elseif arg == "--side-by-side" then + global_opts.layout = "side-by-side" + else + table.insert(args, arg) + end + end + + -- Check if current tab is a diff view and toggle (close) it if so. + -- Keep comments subcommands available while in an active diff session. local current_tab = vim.api.nvim_get_current_tabpage() - if lifecycle.get_session(current_tab) then + local active_session = lifecycle.get_session(current_tab) + local subcommand = args[1] + local is_comments_command = subcommand == "comments" or subcommand == "comment" + if active_session and not is_comments_command then -- Check for unsaved conflict files before closing if not lifecycle.confirm_close_with_unsaved(current_tab) then return -- User cancelled @@ -620,19 +739,6 @@ function M.vscode_diff(opts) return end - -- Pre-parse global flags; strip them so subcommand dispatch sees clean args - local global_opts = {} - local args = {} - for _, arg in ipairs(opts.fargs) do - if arg == "--inline" then - global_opts.layout = "inline" - elseif arg == "--side-by-side" then - global_opts.layout = "side-by-side" - else - table.insert(args, arg) - end - end - if #args == 0 then -- :CodeDiff without arguments opens explorer mode handle_explorer(nil, nil, global_opts) @@ -649,8 +755,6 @@ function M.vscode_diff(opts) end end - local subcommand = args[1] - if subcommand == "merge" then -- :CodeDiff merge - Merge Tool Mode if #args ~= 2 then @@ -769,6 +873,8 @@ function M.vscode_diff(opts) end handle_history(range, file_path, flags, line_range, global_opts) + elseif subcommand == "comments" or subcommand == "comment" then + handle_comments(vim.list_slice(args, 2), opts) elseif subcommand == "install" or subcommand == "install!" then -- :CodeDiff install or :CodeDiff install! -- Handle both :CodeDiff! install and :CodeDiff install! diff --git a/lua/codediff/comments/drain.lua b/lua/codediff/comments/drain.lua new file mode 100644 index 00000000..0d24408f --- /dev/null +++ b/lua/codediff/comments/drain.lua @@ -0,0 +1,130 @@ +--- Formats comments for submission and dispatches through configurable sinks. +---@module codediff.comments.drain + +local model = require("codediff.comments.model") +local SIDE = model.SIDE +local split_lines = model.split_lines +local format_line_ref = model.format_line_ref +local sorted_comments = model.sorted_comments + +---@type codediff.SubmitHook? +local submit_hook = nil + +---@param path string +---@param git_root string? +---@return string +local function short_path(path, git_root) + if not git_root or git_root == "" then + return path + end + local prefix = git_root .. "/" + if path:sub(1, #prefix) == prefix then + return path:sub(#prefix + 1) + end + return path +end + +---@param comments codediff.comments.Comment[] +---@param context codediff.SubmitContext +---@return string +local function format_submission(comments, context) + local ordered = sorted_comments(comments) + local lines = { + string.format("CodeDiff review (%d comment%s)", #ordered, #ordered == 1 and "" or "s"), + } + for _, comment in ipairs(ordered) do + local display_path = short_path(comment.path, context.git_root) + table.insert(lines, "") + table.insert(lines, string.format("%s:%s (%s)", display_path, format_line_ref(comment), SIDE[comment.side].label)) + for _, line in ipairs(split_lines(comment.text)) do + table.insert(lines, " " .. line) + end + end + return table.concat(lines, "\n") +end + +---@param hook codediff.SubmitHook +---@param payload string +---@param comments codediff.comments.Comment[] +---@param context codediff.SubmitContext +---@return boolean ok +---@return string? error +local function run_submit_hook(hook, payload, comments, context) + local ok_call, result_or_err, hook_err = pcall(hook, payload, comments, context) + if not ok_call then + return false, tostring(result_or_err) + end + if result_or_err == false then + return false, hook_err or "submit hook returned false" + end + return true, nil +end + +---@param payload string +---@return boolean ok +---@return string? transport_or_error +local function submit_to_clipboard(payload) + local ok_plus = pcall(vim.fn.setreg, "+", payload) + local ok_unnamed = pcall(vim.fn.setreg, '"', payload) + if not ok_plus and not ok_unnamed then + return false, "failed to write payload to clipboard registers" + end + return true, "clipboard" +end + +---@param payload string +---@param comments codediff.comments.Comment[] +---@param context codediff.SubmitContext +---@return boolean ok +---@return string? transport_or_error +local function submit_to_transport(payload, comments, context) + if submit_hook then + local ok_hook, hook_err = run_submit_hook(submit_hook, payload, comments, context) + if not ok_hook then + return false, hook_err + end + return true, "hook" + end + if type(vim.g.codediff_comment_submit_hook) == "function" then + local ok_hook, hook_err = run_submit_hook(vim.g.codediff_comment_submit_hook, payload, comments, context) + if not ok_hook then + return false, hook_err + end + return true, "hook" + end + return submit_to_clipboard(payload) +end + +local M = {} + +--- Format comments into a submission payload string. +---@param comments codediff.comments.Comment[] +---@param context codediff.SubmitContext +---@return string +function M.format(comments, context) + return format_submission(comments, context) +end + +--- Submit comments through the sink chain: hook -> vim.g hook -> clipboard. +--- Does NOT clear the store. Returns ok, transport_name_or_error. +---@param comments codediff.comments.Comment[] +---@param context codediff.SubmitContext +---@return boolean ok +---@return string? transport_or_error +function M.submit(comments, context) + local payload = format_submission(comments, context) + return submit_to_transport(payload, comments, context) +end + +--- Set or clear the module-level submit hook. +---@param hook codediff.SubmitHook? +function M.set_submit_hook(hook) + submit_hook = hook +end + +--- Reset module state. For tests. +function M._reset_for_tests() + submit_hook = nil +end + +return M diff --git a/lua/codediff/comments/model.lua b/lua/codediff/comments/model.lua new file mode 100644 index 00000000..8fa318da --- /dev/null +++ b/lua/codediff/comments/model.lua @@ -0,0 +1,176 @@ +-- Shared types, constants, and helpers for the comments subsystem. +local M = {} + +---@class codediff.comments.Comment +---@field id integer Unique comment identifier within the tab session +---@field side "left"|"right" Which diff pane the comment belongs to +---@field path string File path the comment is associated with +---@field line integer 1-indexed start line number +---@field end_line? integer 1-indexed end line number (nil means single-line comment) +---@field text string Comment body text +---@field content_lines? string[] Buffer lines the comment was anchored to (for validation on restore) + +---@class codediff.comments.CommentPatch +---@field text? string +---@field line? integer +---@field end_line? integer|false false = clear end_line +---@field path? string +---@field side? "left"|"right" +---@field content_lines? string[]|false false = clear content_lines + +---@alias codediff.comments.Event +---| { type: "comment_added", comment: codediff.comments.Comment } +---| { type: "comment_updated", id: integer, changes: codediff.comments.CommentPatch, reason?: string } +---| { type: "comment_deleted", id: integer, reason?: string } + +---@class codediff.SubmitContext +---@field tabpage integer Tab page ID of the diff session +---@field submitted_at string ISO 8601 timestamp (UTC) +---@field mode string Session mode ("explorer", "standalone", "history") +---@field git_root? string Git root directory +---@field original_revision? string Original revision +---@field modified_revision? string Modified revision +---@field original_path? string Original file path +---@field modified_path? string Modified file path + +---@alias codediff.SubmitHook fun(payload: string, comments: codediff.comments.Comment[], context: codediff.SubmitContext): boolean + +---@class codediff.CommentUIOptions +---@field width integer Floating editor width (columns) +---@field height integer Floating editor height (lines) +---@field opacity integer Window blend (0 = opaque, 100 = fully transparent) +---@field submit_keys string[] Keys to submit from editor +---@field cancel_keys string[] Keys to cancel editor +---@field editor_mode "insert"|"normal" Initial editor mode + +M.EVENT = { + ADDED = "comment_added", + UPDATED = "comment_updated", + DELETED = "comment_deleted", +} + +M.SIDE = { + left = { bufnr_key = "original_bufnr", path_key = "original_path", sort = 0, label = "old", fallback = "(left pane)" }, + right = { bufnr_key = "modified_bufnr", path_key = "modified_path", sort = 1, label = "new", fallback = "(right pane)" }, +} + +---@param text string? +---@return string[] +function M.split_lines(text) + local lines = vim.split(text or "", "\n", { plain = true }) + if #lines == 0 then + return { "" } + end + return lines +end + +---@param text string +---@param max_width integer +---@return string +function M.truncate_text(text, max_width) + if #text <= max_width then + return text + end + if max_width <= 3 then + return text:sub(1, max_width) + end + return text:sub(1, max_width - 3) .. "..." +end + +---@param comment codediff.comments.Comment +---@return string +function M.format_line_ref(comment) + if comment.end_line and comment.end_line > comment.line then + return string.format("%d-%d", comment.line, comment.end_line) + end + return tostring(comment.line) +end + +---@param comments codediff.comments.Comment[] +---@return codediff.comments.Comment[] +function M.sorted_comments(comments) + local ordered = vim.deepcopy(comments) + table.sort(ordered, function(a, b) + local ap = tostring(a.path or "") + local bp = tostring(b.path or "") + if ap == bp then + local al = a.line + local bl = b.line + if al == bl then + local as = (M.SIDE[a.side] or {}).sort or 99 + local bs = (M.SIDE[b.side] or {}).sort or 99 + if as == bs then + return a.id < b.id + end + return as < bs + end + return al < bl + end + return ap < bp + end) + return ordered +end + +---@param value any +---@param default string[] +---@return string[] +function M.normalize_key_list(value, default) + if type(value) == "string" and value ~= "" then + value = { value } + end + if type(value) ~= "table" or #value == 0 then + return default + end + local seen = {} + local out = vim + .iter(value) + :filter(function(key) + if type(key) ~= "string" or key == "" or seen[key] then + return false + end + seen[key] = true + return true + end) + :totable() + return #out > 0 and out or default +end + +---@return codediff.CommentUIOptions +function M.get_ui_options() + local config = require("codediff.config") + local comment_opts = config.options.comments or {} + local ui_opts = comment_opts.ui or {} + + local opacity = ui_opts.opacity + if type(opacity) ~= "number" then + opacity = 0 + end + opacity = math.max(0, math.min(100, opacity)) + + local editor_mode = ui_opts.editor_mode + if type(editor_mode) ~= "string" then + editor_mode = "insert" + else + editor_mode = editor_mode:lower() + end + if editor_mode ~= "normal" then + editor_mode = "insert" + end + + return { + width = ui_opts.width or 72, + height = ui_opts.height or 6, + opacity = opacity, + submit_keys = M.normalize_key_list(ui_opts.submit_keys, { "" }), + cancel_keys = M.normalize_key_list(ui_opts.cancel_keys, { "q" }), + editor_mode = editor_mode, + } +end + +---@return boolean +function M.is_sticky() + local config = require("codediff.config") + return config.options.comments == nil or config.options.comments.sticky ~= false +end + +return M diff --git a/lua/codediff/comments/snapshot_cache.lua b/lua/codediff/comments/snapshot_cache.lua new file mode 100644 index 00000000..eef3e4f3 --- /dev/null +++ b/lua/codediff/comments/snapshot_cache.lua @@ -0,0 +1,104 @@ +-- Persists comment snapshots across CodeDiff toggle/close cycles. +-- Snapshots are keyed by a stable session identity derived from the diff context. +local M = {} + +---@type table +local snapshots = {} + +local SNAPSHOT_DIR = vim.fn.stdpath("state") .. "/codediff/snapshots" + +--- Escape path separators to produce a flat, human-readable filename component. +---@param s string +---@return string +local function escape(s) + return (s:gsub("[\\/:]+", "%%")) +end + +---@param session_id string +---@return string +local function snapshot_path(session_id) + return SNAPSHOT_DIR .. "/" .. session_id +end + +--- Build a stable, human-readable session identity from diff context fields. +--- Produces a %-escaped flat key: `git_root%%original_rev%%modified_rev` +---@param git_root string? +---@param original_revision string? +---@param modified_revision string? +---@return string session_id +function M.session_id(git_root, original_revision, modified_revision) + return table.concat({ + escape(git_root or ""), + escape(original_revision or ""), + escape(modified_revision or ""), + }, "%%") +end + +--- Save a snapshot of comments for a session. +---@param session_id string +---@param comments codediff.comments.Comment[] +function M.save(session_id, comments) + snapshots[session_id] = vim.deepcopy(comments) + + vim.fn.mkdir(SNAPSHOT_DIR, "p") + + local encoded = vim.mpack.encode(comments) + local path = snapshot_path(session_id) + local f = io.open(path, "wb") + if f then + f:write(encoded) + f:close() + end +end + +--- Restore a snapshot for a session. Returns nil if no snapshot exists. +--- Checks memory cache first, then disk. +---@param session_id string +---@return codediff.comments.Comment[]? +function M.restore(session_id) + if snapshots[session_id] then + return vim.deepcopy(snapshots[session_id]) + end + + local path = snapshot_path(session_id) + local f = io.open(path, "rb") + if not f then + return nil + end + local data = f:read("*a") + f:close() + + if not data or #data == 0 then + return nil + end + + local ok, comments = pcall(vim.mpack.decode, data) + if not ok or type(comments) ~= "table" then + return nil + end + + snapshots[session_id] = comments + return vim.deepcopy(comments) +end + +--- Remove a snapshot (e.g., after successful submit). +---@param session_id string +function M.remove(session_id) + snapshots[session_id] = nil + + local path = snapshot_path(session_id) + pcall(os.remove, path) +end + +--- Override snapshot directory for tests. +---@param dir string +function M._set_dir_for_tests(dir) + SNAPSHOT_DIR = dir +end + +--- Reset for tests. +function M._reset_for_tests() + snapshots = {} +end + +return M diff --git a/lua/codediff/comments/store.lua b/lua/codediff/comments/store.lua new file mode 100644 index 00000000..ce6918a0 --- /dev/null +++ b/lua/codediff/comments/store.lua @@ -0,0 +1,215 @@ +-- Pure in-memory event-sourced comment store, keyed by tabpage. +local M = {} + +local model = require("codediff.comments.model") +local EVENT = model.EVENT + +---@class codediff.comments.TabStream +---@field next_id integer +---@field events codediff.comments.Event[] +---@field snapshot codediff.comments.Comment[] + +---@type table +local tabs = {} + +---@type fun(tabpage: integer, event_type: string?)[] +local listeners = {} + +---@param tabpage integer +---@param event_type string? +local function notify_listeners(tabpage, event_type) + for _, cb in ipairs(listeners) do + cb(tabpage, event_type) + end +end + +---@param tabpage integer +---@return codediff.comments.TabStream +local function ensure(tabpage) + if not tabs[tabpage] then + tabs[tabpage] = { next_id = 1, events = {}, snapshot = {} } + end + return tabs[tabpage] +end + +---@param comment codediff.comments.Comment +---@param changes codediff.comments.CommentPatch +---@return codediff.comments.Comment +local function apply_patch(comment, changes) + local patched = vim.deepcopy(comment) + for k, v in pairs(changes) do + if (k == "end_line" or k == "content_lines") and v == false then + patched[k] = nil + else + patched[k] = v + end + end + return patched +end + +---@param events codediff.comments.Event[] +---@return codediff.comments.Comment[] +local function project(events) + local result = {} + for _, ev in ipairs(events) do + if ev.type == EVENT.ADDED then + result[#result + 1] = vim.deepcopy(ev.comment) + elseif ev.type == EVENT.UPDATED then + for i, c in ipairs(result) do + if c.id == ev.id then + result[i] = apply_patch(c, ev.changes) + break + end + end + elseif ev.type == EVENT.DELETED then + for i, c in ipairs(result) do + if c.id == ev.id then + table.remove(result, i) + break + end + end + end + end + return result +end + +---@param snapshot codediff.comments.Comment[] +---@param comment_id integer +---@return codediff.comments.Comment? +local function find(snapshot, comment_id) + return vim.iter(snapshot):find(function(c) + return c.id == comment_id + end) +end + +---@param tabpage integer +---@param draft { side: "left"|"right", path: string, line: integer, end_line?: integer, text: string } +---@return integer id +function M.add(tabpage, draft) + local stream = ensure(tabpage) + local id = stream.next_id + stream.next_id = id + 1 + ---@type codediff.comments.Comment + local comment = { + id = id, + side = draft.side, + path = draft.path, + line = draft.line, + end_line = draft.end_line, + text = draft.text, + content_lines = draft.content_lines, + } + stream.events[#stream.events + 1] = { type = EVENT.ADDED, comment = comment } + stream.snapshot = project(stream.events) + notify_listeners(tabpage, EVENT.ADDED) + return id +end + +---@param tabpage integer +---@param comment_id integer +---@param changes codediff.comments.CommentPatch +---@param reason? string +---@return boolean ok +function M.update(tabpage, comment_id, changes, reason) + local stream = ensure(tabpage) + if not find(stream.snapshot, comment_id) then + return false + end + stream.events[#stream.events + 1] = { + type = EVENT.UPDATED, + id = comment_id, + changes = vim.deepcopy(changes), + reason = reason, + } + stream.snapshot = project(stream.events) + notify_listeners(tabpage, EVENT.UPDATED) + return true +end + +---@param tabpage integer +---@param comment_id integer +---@param reason? string +---@return boolean ok +function M.delete(tabpage, comment_id, reason) + local stream = ensure(tabpage) + if not find(stream.snapshot, comment_id) then + return false + end + stream.events[#stream.events + 1] = { + type = EVENT.DELETED, + id = comment_id, + reason = reason, + } + stream.snapshot = project(stream.events) + notify_listeners(tabpage, EVENT.DELETED) + return true +end + +---@param tabpage integer +---@param comment_id integer +---@return codediff.comments.Comment? +function M.get(tabpage, comment_id) + local stream = tabs[tabpage] + if not stream then + return nil + end + local c = find(stream.snapshot, comment_id) + if c then + return vim.deepcopy(c) + end + return nil +end + +---@param tabpage integer +---@return codediff.comments.Comment[] +function M.list(tabpage) + local stream = tabs[tabpage] + if not stream then + return {} + end + return vim.deepcopy(stream.snapshot) +end + +---@param tabpage integer +---@return integer +function M.count(tabpage) + local stream = tabs[tabpage] + if not stream then + return 0 + end + return #stream.snapshot +end + +---@param tabpage integer +---@return boolean +function M.has_comments(tabpage) + return M.count(tabpage) > 0 +end + +---@param tabpage integer +function M.clear(tabpage) + tabs[tabpage] = nil + notify_listeners(tabpage, nil) +end + +--- Subscribe to store changes. Returns an unsubscribe function. +---@param callback fun(tabpage: integer, event_type: string?) +---@return fun() unsubscribe +function M.subscribe(callback) + listeners[#listeners + 1] = callback + return function() + for i, cb in ipairs(listeners) do + if cb == callback then + table.remove(listeners, i) + return + end + end + end +end + +function M._reset_for_tests() + tabs = {} + listeners = {} +end + +return M diff --git a/lua/codediff/config.lua b/lua/codediff/config.lua index da6fb325..e4fe99a4 100644 --- a/lua/codediff/config.lua +++ b/lua/codediff/config.lua @@ -79,6 +79,19 @@ M.defaults = { view_mode = "list", -- "list" or "tree" for files under commits }, + -- Pending comments UI configuration + comments = { + sticky = false, -- true = always visible, false = show only when cursor is on or near the comment + ui = { + width = 72, -- Floating editor width (columns) + height = 6, -- Floating editor height (lines) + border = "rounded", -- Border style for comment editor popup + editor_mode = "insert", -- "insert" = start in insert mode, "normal" = start in normal mode + submit_keys = { "" }, -- Submit comment (normal mode) + cancel_keys = { "q" }, -- Close editor without saving (normal mode) + }, + }, + -- Keymaps keymaps = { view = { @@ -101,6 +114,12 @@ M.defaults = { align_move = "gm", -- Temporarily align other pane to show paired moved code toggle_layout = "t", -- Toggle diff layout for the current codediff session show_help = "g?", -- Show floating window with available keymaps + comment_add = "ca", -- Open pending comment editor at cursor + comment_edit = "ce", -- Edit pending comment at cursor (or latest on the line) + comment_remove = "cd", -- Remove pending comment at cursor + comment_submit = "cs", -- Submit all pending comments and clear UI + comment_list = "cl", -- List all pending comments + comment_clear = "cc", -- Clear pending comments without submitting }, explorer = { select = "", diff --git a/lua/codediff/ui/comments.lua b/lua/codediff/ui/comments.lua new file mode 100644 index 00000000..5b751bca --- /dev/null +++ b/lua/codediff/ui/comments.lua @@ -0,0 +1,627 @@ +-- Public API facade for CodeDiff pending comments. +-- Orchestrates store, drain, render, editor, and quickfix modules. +local M = {} + +local lifecycle = require("codediff.ui.lifecycle") +local model = require("codediff.comments.model") +local store = require("codediff.comments.store") +local drain = require("codediff.comments.drain") +local render = require("codediff.ui.comments.render") +local editor = require("codediff.ui.comments.editor") +local quickfix = require("codediff.ui.comments.quickfix") +local snapshot_cache = require("codediff.comments.snapshot_cache") + +local SIDE = model.SIDE + +---@return integer? tabpage +---@return table? session +local function get_session_for_current_context() + local current_tab = vim.api.nvim_get_current_tabpage() + local session = lifecycle.get_session(current_tab) + if session then + return current_tab, session + end + + local bufnr = vim.api.nvim_get_current_buf() + local tabpage = lifecycle.find_tabpage_by_buffer(bufnr) + if not tabpage then + return nil, nil + end + + return tabpage, lifecycle.get_session(tabpage) +end + +---@param session table? +---@return boolean +local function is_comment_session(session) + if not session then + return false + end + + if not session.original_bufnr or not session.modified_bufnr or not vim.api.nvim_buf_is_valid(session.original_bufnr) or not vim.api.nvim_buf_is_valid(session.modified_bufnr) then + return false + end + + if session.mode == "explorer" and session.original_path == "" and session.modified_path == "" then + return false + end + + if session.mode == "history" and session.original_path == "" and session.modified_path == "" then + return false + end + + return true +end + +---@param session table +---@param bufnr integer +---@return boolean +local function is_diff_buffer(session, bufnr) + return bufnr == session.original_bufnr or bufnr == session.modified_bufnr +end + +---@param session table +---@param bufnr integer +---@return "left"|"right"|nil +local function side_for_buffer(session, bufnr) + if bufnr == session.original_bufnr then + return "left" + end + if bufnr == session.modified_bufnr then + return "right" + end + return nil +end + +---@param session table +---@param bufnr integer +---@return string +local function buffer_path_for_comment(session, bufnr) + local side = side_for_buffer(session, bufnr) + if side then + local path = session[SIDE[side].path_key] + if path and path ~= "" then + return path + end + return SIDE[side].fallback + end + local name = vim.api.nvim_buf_get_name(bufnr) + return name ~= "" and name or "(unknown pane)" +end + +--- Capture buffer lines covered by a comment range. +---@param bufnr integer +---@param line integer +---@param end_line? integer +---@return string[] +local function capture_content_lines(bufnr, line, end_line) + local last = end_line or line + local count = vim.api.nvim_buf_line_count(bufnr) + if line > count then + return {} + end + last = math.min(last, count) + return vim.api.nvim_buf_get_lines(bufnr, line - 1, last, false) +end + +---@return { tabpage: integer, session: table, bufnr: integer }? +local function require_comment_session() + local tabpage, session = get_session_for_current_context() + if not tabpage or not is_comment_session(session) then + vim.notify("Pending comments are only available in active CodeDiff diff panes", vim.log.levels.WARN) + return nil + end + return { tabpage = tabpage, session = session, bufnr = vim.api.nvim_get_current_buf() } +end + +---@param tabpage integer +---@param session table +---@param notify_on_drop? boolean +local function sync_visible_positions(tabpage, session, notify_on_drop) + local comments = store.list(tabpage) + if #comments == 0 then + return + end + + local sync = render.capture_position_patches(tabpage, session, comments) + + for _, patch in ipairs(sync.updates) do + store.update(tabpage, patch.id, patch.changes, "position_sync") + end + + for _, id in ipairs(sync.stale_ids) do + store.delete(tabpage, id, "stale") + end + + if notify_on_drop and #sync.stale_ids > 0 then + vim.notify(string.format("Dropped %d stale pending comment(s)", #sync.stale_ids), vim.log.levels.WARN) + end +end + +---@param tabpage integer +---@param session table +---@return codediff.SubmitContext +local function make_submit_context(tabpage, session) + return { + tabpage = tabpage, + submitted_at = os.date("!%Y-%m-%dT%H:%M:%SZ"), + mode = session.mode, + git_root = session.git_root, + original_revision = session.original_revision, + modified_revision = session.modified_revision, + original_path = session.original_path, + modified_path = session.modified_path, + } +end + +---@param bufnr integer +---@return integer start_line +---@return integer? end_line +local function read_visual_marks(bufnr) + local ok_start, mark_start = pcall(vim.api.nvim_buf_get_mark, bufnr, "<") + local ok_end, mark_end = pcall(vim.api.nvim_buf_get_mark, bufnr, ">") + if ok_start and ok_end and mark_start[1] > 0 and mark_end[1] > 0 and mark_end[1] > mark_start[1] then + return mark_start[1], mark_end[1] + end + return vim.api.nvim_win_get_cursor(0)[1], nil +end + +---@param comment_id integer? +---@return codediff.comments.Comment? comment +---@return integer? tabpage +local function get_target_comment(comment_id) + local ctx = require_comment_session() + if not ctx then + return nil, nil + end + + sync_visible_positions(ctx.tabpage, ctx.session, true) + + local comments = store.list(ctx.tabpage) + if #comments == 0 then + return nil, nil + end + + if comment_id then + for _, comment in ipairs(comments) do + if comment.id == comment_id then + return comment, ctx.tabpage + end + end + vim.notify(string.format("No pending comment found with id %d", comment_id), vim.log.levels.WARN) + return nil, nil + end + + local bufnr = ctx.bufnr + if not is_diff_buffer(ctx.session, bufnr) then + vim.notify("Move cursor to a CodeDiff diff pane before operating on comments", vim.log.levels.WARN) + return nil, nil + end + + local side = side_for_buffer(ctx.session, bufnr) + local side_meta = side and SIDE[side] + local path = side_meta and ctx.session[side_meta.path_key] or nil + local line = vim.api.nvim_win_get_cursor(0)[1] + for i = #comments, 1, -1 do + local comment = comments[i] + if comment.side == side and comment.path == path then + local start_line = comment.line + local stop_line = comment.end_line or start_line + if line >= start_line and line <= stop_line then + return comment, ctx.tabpage + end + end + end + + return nil, nil +end + +---@param tabpage integer +---@return integer count +function M.clear_session_comments(tabpage) + local count = store.count(tabpage) + render.clear_tab(tabpage) + store.clear(tabpage) + return count +end + +--- Snapshot current comments for a tabpage to disk so they survive toggle/restart. +---@param tabpage integer +local function snapshot_session_comments(tabpage) + if not store.has_comments(tabpage) then + return + end + local sess = lifecycle.get_session(tabpage) + if not sess then + return + end + local sid = snapshot_cache.session_id(sess.git_root, sess.original_revision, sess.modified_revision) + snapshot_cache.save(sid, store.list(tabpage)) +end + +--- Remove any persisted snapshot for a tabpage's session. +---@param tabpage integer +local function remove_snapshot(tabpage) + local sess = lifecycle.get_session(tabpage) + if not sess then + return + end + local sid = snapshot_cache.session_id(sess.git_root, sess.original_revision, sess.modified_revision) + snapshot_cache.remove(sid) +end + +---@param tabpage integer +---@param session table +---@return integer count Number of comments restored +local function restore_session_comments(tabpage, session) + if store.has_comments(tabpage) then + return 0 + end + local sid = snapshot_cache.session_id(session.git_root, session.original_revision, session.modified_revision) + local saved = snapshot_cache.restore(sid) + -- Consume the snapshot so it can't re-trigger (e.g., after user deletes all comments) + snapshot_cache.remove(sid) + if not saved or #saved == 0 then + return 0 + end + + local dropped = 0 + for _, comment in ipairs(saved) do + local side_meta = SIDE[comment.side] + local buf = side_meta and session[side_meta.bufnr_key] + local anchored = comment.content_lines + + -- Drop if content at stored position no longer matches + if anchored and #anchored > 0 and buf and vim.api.nvim_buf_is_valid(buf) and comment.path == (side_meta and session[side_meta.path_key]) then + local current = capture_content_lines(buf, comment.line, comment.end_line) + if table.concat(current, "\n") ~= table.concat(anchored, "\n") then + dropped = dropped + 1 + goto continue + end + end + + store.add(tabpage, { + side = comment.side, + path = comment.path, + line = comment.line, + end_line = comment.end_line, + text = comment.text, + content_lines = anchored, + }) + + ::continue:: + end + + if dropped > 0 then + vim.notify(string.format("Dropped %d stale comment(s) — content no longer found", dropped), vim.log.levels.WARN) + end + + local restored = store.list(tabpage) + if #restored > 0 then + render.reconcile(tabpage, session, restored) + end + return #restored +end + +---@param tabpage integer +---@return codediff.comments.Comment[] +function M.get_comments(tabpage) + local session = lifecycle.get_session(tabpage) + if session then + sync_visible_positions(tabpage, session, false) + end + return store.list(tabpage) +end + +---@return boolean +function M.list_comments() + local ctx = require_comment_session() + if not ctx then + return false + end + + sync_visible_positions(ctx.tabpage, ctx.session, true) + + local comments = store.list(ctx.tabpage) + quickfix.open(comments, ctx.session) + return true +end + +---@param comment_id integer? +---@return boolean +function M.remove_comment(comment_id) + local comment, tabpage = get_target_comment(comment_id) + if not comment or not tabpage then + return false + end + + render.clear_tab(tabpage) + store.delete(tabpage, comment.id) + + local session = lifecycle.get_session(tabpage) + if session then + render.reconcile(tabpage, session, store.list(tabpage)) + end + + return true +end + +---@param text string? +---@param range_line1 integer? +---@param range_line2 integer? +---@return boolean +function M.add_comment(text, range_line1, range_line2) + local tabpage, session = get_session_for_current_context() + if not tabpage or not is_comment_session(session) then + vim.notify("Pending comments are only available in active CodeDiff diff panes", vim.log.levels.WARN) + return false + end + + local bufnr = vim.api.nvim_get_current_buf() + if not is_diff_buffer(session, bufnr) then + vim.notify("Move cursor to a CodeDiff diff pane before adding a comment", vim.log.levels.WARN) + return false + end + + local message = vim.trim(text or "") + if message == "" then + return false + end + + local line = range_line1 or vim.api.nvim_win_get_cursor(0)[1] + local end_line = nil + if range_line1 and range_line2 and range_line2 > range_line1 then + end_line = range_line2 + end + + store.add(tabpage, { + side = side_for_buffer(session, bufnr), + path = buffer_path_for_comment(session, bufnr), + line = line, + end_line = end_line, + text = message, + content_lines = capture_content_lines(bufnr, line, end_line), + }) + + render.reconcile(tabpage, session, store.list(tabpage)) + return true +end + +---@param opts? { visual?: boolean, range_line1?: integer, range_line2?: integer } +---@return boolean +function M.open_add_editor(opts) + opts = opts or {} + local ctx = require_comment_session() + if not ctx then + return false + end + + local bufnr = ctx.bufnr + local line, end_line + if opts.range_line1 and opts.range_line2 and opts.range_line2 > opts.range_line1 then + line = opts.range_line1 + end_line = opts.range_line2 + elseif opts.visual then + line, end_line = read_visual_marks(bufnr) + else + line = vim.api.nvim_win_get_cursor(0)[1] + end + + if not is_diff_buffer(ctx.session, bufnr) then + vim.notify("Move cursor to a CodeDiff diff pane before adding a comment", vim.log.levels.WARN) + return false + end + + local title = end_line and string.format("Add Comment (L%d-%d)", line, end_line) or "Add Comment" + return editor.open({ + title = title, + initial_text = "", + on_submit = function(text) + local message = vim.trim(text) + if message == "" then + return false + end + store.add(ctx.tabpage, { + side = side_for_buffer(ctx.session, bufnr), + path = buffer_path_for_comment(ctx.session, bufnr), + line = line, + end_line = end_line, + text = message, + content_lines = capture_content_lines(bufnr, line, end_line), + }) + render.reconcile(ctx.tabpage, ctx.session, store.list(ctx.tabpage)) + return true + end, + }) +end + +---@param comment_id integer? +---@param new_text string? +---@return boolean +function M.edit_comment(comment_id, new_text) + local comment, tabpage = get_target_comment(comment_id) + if not comment or not tabpage then + return false + end + + local message = vim.trim(new_text or "") + if message == "" then + vim.notify("Comment text cannot be empty", vim.log.levels.WARN) + return false + end + + if comment.text == message then + return true + end + + store.update(tabpage, comment.id, { text = message }) + + local session = lifecycle.get_session(tabpage) + if session then + render.reconcile(tabpage, session, store.list(tabpage)) + end + + return true +end + +---@param comment_id integer? +---@return boolean +function M.open_edit_editor(comment_id) + local comment, tabpage = get_target_comment(comment_id) + if not comment or not tabpage then + return false + end + + return editor.open({ + title = string.format("Edit Comment c%d", comment.id), + initial_text = comment.text, + cursor_to_end = true, + on_submit = function(text) + return M.edit_comment(comment.id, text) + end, + }) +end + +---@return boolean +function M.clear_comments() + local ctx = require_comment_session() + if not ctx then + return false + end + + remove_snapshot(ctx.tabpage) + M.clear_session_comments(ctx.tabpage) + editor.close_active() + return true +end + +---@return boolean +function M.submit_comments() + local ctx = require_comment_session() + if not ctx then + return false + end + + sync_visible_positions(ctx.tabpage, ctx.session, true) + + local comments = store.list(ctx.tabpage) + if #comments == 0 then + return false + end + + local context = make_submit_context(ctx.tabpage, ctx.session) + local ok_submit, transport_or_err = drain.submit(comments, context) + if not ok_submit then + vim.notify("Failed to submit pending comments: " .. tostring(transport_or_err), vim.log.levels.ERROR) + return false + end + + remove_snapshot(ctx.tabpage) + M.clear_session_comments(ctx.tabpage) + editor.close_active() + + return true +end + +function M.setup() + if M._setup_done then + return + end + M._setup_done = true + + render.setup() + + local group = vim.api.nvim_create_augroup("CodeDiffComments", { clear = true }) + + vim.api.nvim_create_autocmd("User", { + group = group, + pattern = "CodeDiffClose", + callback = function(args) + editor.close_active() + if args and args.data and args.data.tabpage then + snapshot_session_comments(args.data.tabpage) + M.clear_session_comments(args.data.tabpage) + end + end, + }) + + vim.api.nvim_create_autocmd({ "WinScrolled", "BufWinEnter", "TabEnter" }, { + group = group, + callback = function() + local tabpage = vim.api.nvim_get_current_tabpage() + local session = lifecycle.get_session(tabpage) + if not session then + return + end + -- Attempt snapshot restore if store is empty + if not store.has_comments(tabpage) then + restore_session_comments(tabpage, session) + end + if store.has_comments(tabpage) then + render.reconcile(tabpage, session, store.list(tabpage)) + end + end, + }) + + -- Non-sticky comment visibility: show/hide popups based on viewport. + vim.api.nvim_create_autocmd({ "WinScrolled", "CursorMoved", "CursorMovedI" }, { + group = group, + callback = function() + if model.is_sticky() then + return + end + local tabpage = vim.api.nvim_get_current_tabpage() + local session = lifecycle.get_session(tabpage) + if not session or not store.has_comments(tabpage) then + return + end + render.update_viewport_popups(tabpage, session, store.list(tabpage)) + end, + }) + + vim.api.nvim_create_autocmd("VimResized", { + group = group, + callback = function() + for _, tabpage in ipairs(vim.api.nvim_list_tabpages()) do + local session = lifecycle.get_session(tabpage) + if session and store.has_comments(tabpage) then + render.reconcile(tabpage, session, store.list(tabpage)) + end + end + end, + }) + + vim.api.nvim_create_autocmd("ColorScheme", { + group = group, + callback = function() + render.refresh_highlights() + end, + }) + + vim.api.nvim_create_autocmd("VimLeavePre", { + group = group, + callback = function() + for _, tabpage in ipairs(vim.api.nvim_list_tabpages()) do + snapshot_session_comments(tabpage) + end + end, + }) +end + +function M._reset_for_tests() + editor.close_active() + render._reset_for_tests() + store._reset_for_tests() + drain._reset_for_tests() + snapshot_cache._reset_for_tests() +end + +---@param hook codediff.SubmitHook? +function M.set_submit_hook(hook) + if hook ~= nil and type(hook) ~= "function" then + error("set_submit_hook expects a function or nil") + end + drain.set_submit_hook(hook) +end + +return M diff --git a/lua/codediff/ui/comments/editor.lua b/lua/codediff/ui/comments/editor.lua new file mode 100644 index 00000000..06baf83a --- /dev/null +++ b/lua/codediff/ui/comments/editor.lua @@ -0,0 +1,256 @@ +-- Floating editor popup for adding/editing comments. +-- Owns the scratch buffer + floating window lifecycle +local model = require("codediff.comments.model") +local float = require("codediff.ui.lib.float") + +local split_lines = model.split_lines + +local WINHIGHLIGHT = "NormalFloat:NormalFloat,FloatBorder:CodeDiffCommentBorder,FloatTitle:CodeDiffCommentMeta,FloatFooter:CodeDiffCommentMeta" + +---@class codediff.ui.comments.ActiveEditor +---@field id string +---@field winid integer +---@field bufnr integer +---@field close fun() + +---@type codediff.ui.comments.ActiveEditor? +local active_editor = nil + +-- --------------------------------------------------------------------------- +-- Internal helpers +-- --------------------------------------------------------------------------- + +---@param key string +---@return string +local function key_label(key) + local labels = { + [""] = "enter", + [""] = "shift+enter", + [""] = "shift+enter", + [""] = "shift+enter", + [""] = "ctrl+enter", + [""] = "ctrl+g", + [""] = "esc", + } + if labels[key] then + return labels[key] + end + if type(key) == "string" and key:match("^<.+>$") then + return key:sub(2, -2):lower() + end + return tostring(key) +end + +---@param ui codediff.CommentUIOptions +---@return { width: integer, height: integer } +local function get_editor_layout(ui) + local anchor_win = vim.api.nvim_get_current_win() + return { + width = math.min(ui.width, math.max(1, vim.api.nvim_win_get_width(anchor_win) - 2)), + height = math.min(ui.height, math.max(1, vim.api.nvim_win_get_height(anchor_win) - 2)), + } +end + +local function force_normal_mode() + pcall(vim.cmd, "stopinsert") + local mode = vim.api.nvim_get_mode().mode + if mode:sub(1, 1) == "i" then + local esc = vim.api.nvim_replace_termcodes("", true, false, true) + vim.api.nvim_feedkeys(esc, "n", false) + end +end + +---@param bufnr integer +---@param submit_keys string[] +---@param cancel_keys string[] +---@param submit_comment fun() +---@param close_editor fun() +---@return { mode: string, key: string }[] +local function apply_editor_keymaps(bufnr, submit_keys, cancel_keys, submit_comment, close_editor) + local seen = {} + local applied = {} + local function map_key(mode, key, cb, group) + local map_id = string.format("%s:%s:%s", group, mode, key) + if seen[map_id] then + return + end + seen[map_id] = true + vim.keymap.set(mode, key, cb, { + buffer = bufnr, + noremap = true, + silent = true, + nowait = true, + }) + table.insert(applied, { mode = mode, key = key }) + end + for _, key in ipairs(submit_keys) do + if type(key) == "string" and key ~= "" then + map_key("n", key, submit_comment, "submit") + end + end + for _, key in ipairs(cancel_keys) do + if type(key) == "string" and key ~= "" then + map_key("n", key, close_editor, "cancel") + end + end + return applied +end + +---@param bufnr integer +---@return string +local function read_editor_text(bufnr) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + return vim.trim(table.concat(lines, "\n")) +end + +---@param winid integer +---@param text string? +local function move_editor_cursor_to_end(winid, text) + local lines = split_lines(text) + local target_line = math.max(1, #lines) + local target_text = lines[target_line] or "" + local target_col = #target_text + pcall(vim.api.nvim_win_set_cursor, winid, { target_line, target_col }) +end + +---@param ui codediff.CommentUIOptions +---@return string +local function cancel_hint(ui) + return ui.cancel_keys[1] or "" +end + +-- --------------------------------------------------------------------------- +-- Public API +-- --------------------------------------------------------------------------- + +local M = {} + +--- Close the active editor if any. +function M.close_active() + if active_editor then + active_editor.close() + end +end + +--- Whether an editor is currently open. +---@return boolean +function M.is_active() + return active_editor ~= nil +end + +--- Open a comment editor popup. Only one can be active at a time. +---@param opts? { title?: string, initial_text?: string, cursor_to_end?: boolean, on_submit?: fun(text: string): boolean } +---@return boolean +function M.open(opts) + opts = opts or {} + M.close_active() + + local ui = model.get_ui_options() + local layout = get_editor_layout(ui) + + local submit_key_hint = ui.submit_keys[1] or "" + local cancel_key_hint = cancel_hint(ui) + local title = string.format(" %s ", opts.title or "CodeDiff Comment") + local footer = string.format("%s close | %s submit", key_label(cancel_key_hint), key_label(submit_key_hint)) + + local previous_win = vim.api.nvim_get_current_win() + local bufnr = float.create_scratch_buf({ filetype = "markdown" }) + + local border = "rounded" + local win_config = { + relative = "cursor", + row = 1, + col = 0, + width = layout.width, + height = layout.height, + style = "minimal", + border = border, + zindex = 220, + } + + float.apply_title_footer(win_config, border, title, "left", footer, "left") + + local winid = vim.api.nvim_open_win(bufnr, true, win_config) + float.set_float_win_options(winid, WINHIGHLIGHT) + vim.wo[winid].wrap = true + vim.wo[winid].linebreak = true + vim.wo[winid].winblend = ui.opacity + + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, split_lines(opts.initial_text)) + if opts.cursor_to_end then + move_editor_cursor_to_end(winid, opts.initial_text) + end + + local editor_id = tostring((vim.uv or vim.loop).hrtime()) + local closed = false + + local function close_editor() + if closed then + return + end + closed = true + + if active_editor and active_editor.id == editor_id then + active_editor = nil + end + + vim.schedule(function() + force_normal_mode() + + if vim.api.nvim_win_is_valid(winid) then + pcall(vim.api.nvim_win_close, winid, true) + end + + if vim.api.nvim_win_is_valid(previous_win) then + pcall(vim.api.nvim_set_current_win, previous_win) + end + + force_normal_mode() + end) + end + + local function submit_comment() + if not vim.api.nvim_buf_is_valid(bufnr) then + return + end + + local text = read_editor_text(bufnr) + if text == "" then + close_editor() + return + end + + local ok_submit = opts.on_submit and opts.on_submit(text) + if ok_submit then + close_editor() + end + end + + apply_editor_keymaps(bufnr, ui.submit_keys, ui.cancel_keys, submit_comment, close_editor) + + if ui.editor_mode == "insert" then + vim.cmd("startinsert") + if opts.cursor_to_end then + move_editor_cursor_to_end(winid, opts.initial_text) + end + end + + active_editor = { + id = editor_id, + winid = winid, + bufnr = bufnr, + close = close_editor, + } + + return true +end + +--- Reset for tests. +function M._reset_for_tests() + if active_editor and active_editor.close then + pcall(active_editor.close) + end + active_editor = nil +end + +return M diff --git a/lua/codediff/ui/comments/quickfix.lua b/lua/codediff/ui/comments/quickfix.lua new file mode 100644 index 00000000..907aa6e7 --- /dev/null +++ b/lua/codediff/ui/comments/quickfix.lua @@ -0,0 +1,385 @@ +-- Builds and opens quickfix lists for pending comments. +-- Uses custom navigation to jump to the correct CodeDiff diff pane +-- instead of Neovim's default quickfix buffer-opening behavior. +local model = require("codediff.comments.model") + +local SIDE = model.SIDE +local split_lines = model.split_lines +local truncate_text = model.truncate_text +local format_line_ref = model.format_line_ref +local sorted_comments = model.sorted_comments +local store = require("codediff.comments.store") + +---@type fun()? Active store subscription unsubscribe function +local active_unsub = nil + +---@param path string +---@return boolean +local function is_absolute_path(path) + return path:sub(1, 1) == "/" or path:match("^%a:[/\\]") ~= nil +end + +---@param comment codediff.comments.Comment +---@param session table? +---@return string? +local function quickfix_filename_for_comment(comment, session) + local path = comment.path + if type(path) ~= "string" or path == "" then + return nil + end + if path:sub(1, 1) == "(" and path:sub(-1) == ")" then + return nil + end + if is_absolute_path(path) then + return path + end + if session and type(session.git_root) == "string" and session.git_root ~= "" then + return session.git_root .. "/" .. path + end + return path +end + +---@param comments codediff.comments.Comment[] +---@param session table? +---@return table[] +local function build_quickfix_items(comments, session) + local items = {} + for _, comment in ipairs(sorted_comments(comments)) do + local lines = split_lines(comment.text) + local line = tonumber(comment.line) or 1 + local line_ref = format_line_ref(comment) + local item = { + lnum = math.max(1, line), + col = 1, + type = "I", + text = string.format("c%d [%s] %s:%s %s", comment.id, tostring(comment.side or "?"), tostring(comment.path or ""), line_ref, truncate_text(lines[1] or "", 120)), + } + -- Use filename only (never bufnr) so Neovim doesn't open the diff buffer + -- in a wrong window. Custom handler navigates to the correct pane. + local filename = quickfix_filename_for_comment(comment, session) + if filename then + item.filename = filename + end + table.insert(items, item) + end + return items +end + +--- Parse comment ID from a quickfix item text field. +---@param text string +---@return integer? +local function parse_comment_id(text) + local id = text:match("c(%d+) ") + return id and tonumber(id) or nil +end + +---@param explorer table +---@param path string +---@return table?, string? +local function find_file_in_explorer(explorer, path) + local sr = explorer and explorer.status_result + if not sr then + return nil, nil + end + local groups = { "conflicts", "unstaged", "staged" } + for _, group in ipairs(groups) do + for _, f in ipairs(sr[group] or {}) do + if f.path == path then + -- Ensure the returned data has the group field set, + -- since status_result entries don't include it but + -- explorer.on_file_select needs it for tree highlighting. + local data = vim.deepcopy(f) + data.group = group + return data, group + end + end + end + return nil, nil +end + +--- Normalize a path to its relative form (strip git_root prefix if present). +---@param path string +---@param git_root string? +---@return string +local function normalize_path(path, git_root) + if git_root and git_root ~= "" and path:sub(1, #git_root + 1) == git_root .. "/" then + return path:sub(#git_root + 2) + end + return path +end + +---@param session table +---@param comment_path string +---@return boolean +local function session_shows_path(session, comment_path) + if not session or not comment_path then + return false + end + local git_root = session.git_root + local norm_comment = normalize_path(comment_path, git_root) + local norm_orig = normalize_path(session.original_path or "", git_root) + local norm_mod = normalize_path(session.modified_path or "", git_root) + return norm_comment == norm_orig or norm_comment == norm_mod +end + +---@param tabpage integer +---@param comment_id integer +---@param side_meta table +local function jump_to_comment_line(tabpage, comment_id, side_meta) + local lifecycle = require("codediff.ui.lifecycle") + local render = require("codediff.ui.comments.render") + + local session = lifecycle.get_session(tabpage) + if not session then + return + end + local comment = store.get(tabpage, comment_id) + if not comment then + return + end + + local target_win = session[side_meta.bufnr_key == "original_bufnr" and "original_win" or "modified_win"] + if not target_win or not vim.api.nvim_win_is_valid(target_win) then + return + end + + vim.api.nvim_set_current_win(target_win) + local line = math.max(1, comment.line) + local buf_lines = vim.api.nvim_buf_line_count(vim.api.nvim_win_get_buf(target_win)) + line = math.min(line, buf_lines) + vim.api.nvim_win_set_cursor(target_win, { line, 0 }) + vim.cmd("normal! zz") + + render.reconcile(tabpage, session, store.list(tabpage)) +end + +--- Navigate to the correct CodeDiff diff pane for a comment. +--- Focuses the diff window and positions the cursor on the comment line. +--- If the comment's file is not currently displayed, switches the diff view +--- via the explorer's on_file_select before jumping. +---@param comment_id integer +---@return boolean +local function navigate_to_comment(comment_id) + local lifecycle = require("codediff.ui.lifecycle") + + for _, tabpage in ipairs(vim.api.nvim_list_tabpages()) do + local session = lifecycle.get_session(tabpage) + if not session then + goto next_tab + end + + local comments = store.list(tabpage) + for _, comment in ipairs(comments) do + if comment.id == comment_id then + local side_meta = SIDE[comment.side] + if not side_meta then + return false + end + + -- Switch to the tabpage if needed + if vim.api.nvim_get_current_tabpage() ~= tabpage then + vim.api.nvim_set_current_tabpage(tabpage) + end + + -- Check if the session is already showing this file + if session_shows_path(session, comment.path) then + -- Same file — just jump to the line + jump_to_comment_line(tabpage, comment_id, side_meta) + return true + end + + -- Different file — use the explorer to switch + local explorer = lifecycle.get_explorer(tabpage) + if not explorer or not explorer.on_file_select then + -- No explorer available, fall back to direct jump + jump_to_comment_line(tabpage, comment_id, side_meta) + return true + end + + -- Normalize comment path to relative (explorer uses relative paths) + local lookup_path = normalize_path(comment.path, session.git_root) + local file_data, _ = find_file_in_explorer(explorer, lookup_path) + if not file_data then + -- File not found in explorer status, fall back to direct jump + jump_to_comment_line(tabpage, comment_id, side_meta) + return true + end + + -- Trigger file selection (this calls view.update via vim.schedule internally) + explorer.on_file_select(file_data) + + -- Wait for the view to switch to the new file before jumping to line. + -- on_file_select schedules view.update asynchronously (and may involve + -- git operations), so we poll until the session path matches. + local target_path = normalize_path(comment.path, session.git_root) + local attempts = 0 + local max_attempts = 20 -- ~1s max + local function try_jump() + attempts = attempts + 1 + local updated_session = lifecycle.get_session(tabpage) + if updated_session and session_shows_path(updated_session, target_path) then + jump_to_comment_line(tabpage, comment_id, side_meta) + return + end + if attempts < max_attempts then + vim.defer_fn(try_jump, 50) + end + end + vim.defer_fn(try_jump, 50) + + return true + end + end + ::next_tab:: + end + + return false +end + +--- Custom handler for the quickfix window. +local function on_quickfix_select() + local line = vim.api.nvim_get_current_line() + local comment_id = parse_comment_id(line:match("|(.*)$") or line) + if not comment_id then + -- Fallback to default behavior + vim.cmd("cc " .. vim.fn.line(".")) + return + end + + if not navigate_to_comment(comment_id) then + -- Comment no longer exists or session closed; fallback + vim.cmd("cc " .. vim.fn.line(".")) + end +end + +--- Refresh quickfix items in-place if the quickfix window is open with our title. +---@param tabpage integer +local function refresh_quickfix(tabpage) + local qf_info = vim.fn.getqflist({ winid = 0, title = "" }) + if not qf_info.winid or qf_info.winid == 0 then + return + end + if not (qf_info.title or ""):match("^CodeDiff pending comments") then + return + end + + local lifecycle = require("codediff.ui.lifecycle") + local session = lifecycle.get_session(tabpage) + local comments = store.list(tabpage) + local items = build_quickfix_items(comments, session) + local title = string.format("CodeDiff pending comments (%d)", #items) + + -- Preserve cursor position + local cursor = vim.api.nvim_win_get_cursor(qf_info.winid) + vim.fn.setqflist({}, "r", { title = title, items = items }) + -- Restore cursor (clamp to new list size) + local new_count = #items + if new_count > 0 then + cursor[1] = math.min(cursor[1], new_count) + pcall(vim.api.nvim_win_set_cursor, qf_info.winid, cursor) + end +end + +local function unsubscribe() + if active_unsub then + active_unsub() + active_unsub = nil + end +end + +local M = {} + +--- Check if the quickfix window is currently open with a CodeDiff comments title. +---@return integer? winid The quickfix window ID if open with our title, nil otherwise +local function find_open_comments_qf() + local qf_info = vim.fn.getqflist({ winid = 0, title = "" }) + if qf_info.winid and qf_info.winid ~= 0 and (qf_info.title or ""):match("^CodeDiff pending comments") then + return qf_info.winid + end + return nil +end + +--- Close the comments quickfix if it is currently open. +---@return boolean closed True if a comments quickfix was found and closed +function M.close_if_open() + local existing = find_open_comments_qf() + if existing then + vim.cmd("cclose") + unsubscribe() + return true + end + return false +end + +--- Toggle the quickfix list for the given comments. +--- If already open, closes it. Otherwise builds and opens it. +---@param comments codediff.comments.Comment[] +---@param session table? The lifecycle session +---@return boolean opened True if quickfix was opened (false if closed) +function M.open(comments, session) + -- Toggle: close if already showing our comments quickfix + local existing = find_open_comments_qf() + if existing then + vim.cmd("cclose") + unsubscribe() + return false + end + + local items = build_quickfix_items(comments, session) + local title = string.format("CodeDiff pending comments (%d)", #items) + vim.fn.setqflist({}, " ", { title = title, items = items }) + vim.cmd("copen") + + -- Set custom mapping on the quickfix buffer to navigate to + -- the correct CodeDiff diff pane instead of opening a split. + local qf_bufnr = vim.api.nvim_get_current_buf() + vim.keymap.set("n", "", on_quickfix_select, { + buffer = qf_bufnr, + noremap = true, + silent = true, + nowait = true, + desc = "Navigate to CodeDiff comment", + }) + + -- Bind the comment_list toggle key on the quickfix buffer so the user + -- can press the same key to close the list from inside the quickfix window. + local list_key = require("codediff.config").options.keymaps.view.comment_list + if list_key then + vim.keymap.set("n", list_key, function() + M.close_if_open() + end, { + buffer = qf_bufnr, + noremap = true, + silent = true, + nowait = true, + desc = "Close CodeDiff comments list", + }) + end + + -- Subscribe to store changes to keep quickfix in sync + unsubscribe() + active_unsub = store.subscribe(function(tabpage) + vim.schedule(function() + refresh_quickfix(tabpage) + end) + end) + + -- Clean up subscription when quickfix buffer is wiped + vim.api.nvim_create_autocmd("BufWipeout", { + buffer = qf_bufnr, + once = true, + callback = unsubscribe, + }) + + return true +end + +--- Build quickfix items without opening. For testing or external use. +---@param comments codediff.comments.Comment[] +---@param session table? +---@return table[] items +function M.build_items(comments, session) + return build_quickfix_items(comments, session) +end + +return M diff --git a/lua/codediff/ui/comments/render.lua b/lua/codediff/ui/comments/render.lua new file mode 100644 index 00000000..1640330e --- /dev/null +++ b/lua/codediff/ui/comments/render.lua @@ -0,0 +1,621 @@ +-- Render module: owns all extmark and popup rendering for comments. +-- Maintains a parallel state of UI handles separate from the store's domain data. +local M = {} + +local model = require("codediff.comments.model") +local float = require("codediff.ui.lib.float") +local config = require("codediff.config") + +local SIDE = model.SIDE +local split_lines = model.split_lines +local truncate_text = model.truncate_text + +local ns = vim.api.nvim_create_namespace("codediff-comments") + +---@class codediff.ui.comments.Handle +---@field bufnr integer Buffer the extmark is placed in +---@field extmark_id integer +---@field popup_winid? integer +---@field popup_bufnr? integer + +--- handles[tabpage][comment_id] = Handle +---@type table> +local handles = {} + +-- --------------------------------------------------------------------------- +-- Highlights +-- --------------------------------------------------------------------------- + +local DEFAULT_BORDER_FG = 0xA6ADC8 + +local function setup_comment_highlights() + local border_fg = DEFAULT_BORDER_FG + local border_bg = "NONE" + local ok_hl, normal_hl = pcall(vim.api.nvim_get_hl, 0, { name = "Normal", link = false }) + if ok_hl and normal_hl then + if type(normal_hl.fg) == "number" then + border_fg = normal_hl.fg + end + if type(normal_hl.bg) == "number" then + border_bg = normal_hl.bg + end + end + + local meta_fg = border_fg + local ok_comment_hl, comment_hl = pcall(vim.api.nvim_get_hl, 0, { name = "Comment", link = false }) + if ok_comment_hl and comment_hl and type(comment_hl.fg) == "number" then + meta_fg = comment_hl.fg + end + + vim.api.nvim_set_hl(0, "CodeDiffCommentBorder", { fg = border_fg, bg = border_bg }) + vim.api.nvim_set_hl(0, "CodeDiffCommentMeta", { fg = meta_fg, bg = border_bg }) +end + +-- --------------------------------------------------------------------------- +-- Internal helpers +-- --------------------------------------------------------------------------- + +---@return string +local function comment_winhighlight() + return "NormalFloat:NormalFloat,FloatBorder:CodeDiffCommentBorder,FloatTitle:CodeDiffCommentMeta,FloatFooter:CodeDiffCommentMeta" +end + +---@return string? +local function comment_hint_text() + local keymaps = (config.options.keymaps or {}).view or {} + local hint_parts = {} + if keymaps.comment_edit then + table.insert(hint_parts, "edit " .. keymaps.comment_edit) + end + if keymaps.comment_remove then + table.insert(hint_parts, "delete " .. keymaps.comment_remove) + end + if #hint_parts == 0 then + return nil + end + return table.concat(hint_parts, " | ") +end + +---@param tabpage integer +---@param bufnr integer +---@return integer? +local function find_window_for_buffer(tabpage, bufnr) + if not tabpage or not vim.api.nvim_tabpage_is_valid(tabpage) then + return nil + end + for _, winid in ipairs(vim.api.nvim_tabpage_list_wins(tabpage)) do + if vim.api.nvim_win_is_valid(winid) and vim.api.nvim_win_get_buf(winid) == bufnr then + return winid + end + end + return nil +end + +---@param comment codediff.comments.Comment +---@param session table +---@return boolean +local function comment_visible_in_session(comment, session) + local side_meta = SIDE[comment.side] + local active_path = side_meta and session[side_meta.path_key] or nil + if type(active_path) ~= "string" or active_path == "" then + return false + end + return comment.path == active_path +end + +---@param tabpage integer +---@return table +local function get_tab_handles(tabpage) + if not handles[tabpage] then + handles[tabpage] = {} + end + return handles[tabpage] +end + +-- --------------------------------------------------------------------------- +-- Handle lifecycle +-- --------------------------------------------------------------------------- + +---@param handle codediff.ui.comments.Handle +local function close_handle(handle) + if handle.popup_winid and vim.api.nvim_win_is_valid(handle.popup_winid) then + pcall(vim.api.nvim_win_close, handle.popup_winid, true) + end + handle.popup_winid = nil + + if handle.popup_bufnr and vim.api.nvim_buf_is_valid(handle.popup_bufnr) then + pcall(vim.api.nvim_buf_delete, handle.popup_bufnr, { force = true }) + end + handle.popup_bufnr = nil + + if handle.extmark_id and vim.api.nvim_buf_is_valid(handle.bufnr) then + pcall(vim.api.nvim_buf_del_extmark, handle.bufnr, ns, handle.extmark_id) + end +end + +---@param tabpage integer +---@param comment_id integer +local function clear_handle(tabpage, comment_id) + local tab = handles[tabpage] + if not tab then + return + end + local handle = tab[comment_id] + if not handle then + return + end + close_handle(handle) + tab[comment_id] = nil +end + +-- --------------------------------------------------------------------------- +-- Popup rendering +-- --------------------------------------------------------------------------- + +---@param tabpage integer +---@param comment codediff.comments.Comment +---@param handle codediff.ui.comments.Handle +---@param comments codediff.comments.Comment[] +---@param session table +---@return boolean +local function render_comment_popup(tabpage, comment, handle, comments, session) + if not vim.api.nvim_buf_is_valid(handle.bufnr) then + return false + end + + local anchor_win = find_window_for_buffer(tabpage, handle.bufnr) + if not anchor_win then + if handle.popup_winid and vim.api.nvim_win_is_valid(handle.popup_winid) then + pcall(vim.api.nvim_win_close, handle.popup_winid, true) + end + handle.popup_winid = nil + return true + end + + local ui = model.get_ui_options() + local hint = comment_hint_text() + local lines = split_lines(comment.text) + + local max_line_width = 0 + for _, line in ipairs(lines) do + max_line_width = math.max(max_line_width, vim.fn.strdisplaywidth(line)) + end + if max_line_width == 0 then + max_line_width = 1 + end + + local win_width = vim.api.nvim_win_get_width(anchor_win) + local max_popup_width = math.max(24, math.floor(win_width * 0.52)) + local hint_width = hint and vim.fn.strdisplaywidth(hint) or 0 + local content_width = math.min(math.max(max_line_width, hint_width), max_popup_width) + + local display_lines = {} + for _, line in ipairs(lines) do + table.insert(display_lines, truncate_text(line, content_width)) + end + + -- Compute stack offset from handles on the same line with lower id. + local stack_offset = 0 + local tab = handles[tabpage] or {} + for _, candidate in ipairs(comments) do + if + candidate.id ~= comment.id + and candidate.side == comment.side + and candidate.path == comment.path + and candidate.line == comment.line + and candidate.id < comment.id + and comment_visible_in_session(candidate, session) + and tab[candidate.id] + then + stack_offset = stack_offset + 1 + end + end + + local popup_bufnr = handle.popup_bufnr + if not popup_bufnr or not vim.api.nvim_buf_is_valid(popup_bufnr) then + popup_bufnr = float.create_scratch_buf() + handle.popup_bufnr = popup_bufnr + end + + local win_config = { + relative = "win", + win = anchor_win, + bufpos = { comment.line - 1, 0 }, + row = stack_offset, + col = math.max(2, win_width - content_width - 6), + width = content_width, + height = math.max(1, #display_lines), + style = "minimal", + focusable = false, + zindex = 210, + noautocmd = true, + } + + local border = "rounded" + win_config.border = border + + float.apply_title_footer(win_config, border, string.format(" c%d ", comment.id), "left", hint or nil, "left") + + local popup_winid = float.open_or_reconfigure(handle.popup_winid, popup_bufnr, false, win_config) + handle.popup_winid = popup_winid + + vim.bo[popup_bufnr].modifiable = true + vim.api.nvim_buf_set_lines(popup_bufnr, 0, -1, false, display_lines) + vim.bo[popup_bufnr].modifiable = false + + float.set_float_win_options(popup_winid, comment_winhighlight()) + vim.wo[popup_winid].winblend = ui.opacity + + return true +end + +-- --------------------------------------------------------------------------- +-- Extmark rendering +-- --------------------------------------------------------------------------- + +---@param comment codediff.comments.Comment +---@param handle codediff.ui.comments.Handle +---@return boolean +local function render_extmark(comment, handle) + if not vim.api.nvim_buf_is_valid(handle.bufnr) then + return false + end + + local extmark_opts = { + sign_text = "C", + sign_hl_group = "DiagnosticSignInfo", + number_hl_group = "DiagnosticSignInfo", + priority = 220, + } + + if handle.extmark_id and handle.extmark_id ~= 0 then + extmark_opts.id = handle.extmark_id + end + + if comment.end_line and comment.end_line > comment.line then + local buf_line_count = vim.api.nvim_buf_line_count(handle.bufnr) + local clamped_end = math.min(comment.end_line, buf_line_count) + extmark_opts.end_row = clamped_end - 1 + extmark_opts.end_col = 0 + end + + local ok, extmark_id = pcall(vim.api.nvim_buf_set_extmark, handle.bufnr, ns, comment.line - 1, 0, extmark_opts) + if not ok then + return false + end + + handle.extmark_id = extmark_id + return true +end + +-- --------------------------------------------------------------------------- +-- Position sync +-- --------------------------------------------------------------------------- + +---@param handle codediff.ui.comments.Handle +---@return { line: integer, end_line: integer? }? +local function sync_handle_position(handle) + if not vim.api.nvim_buf_is_valid(handle.bufnr) then + return nil + end + if not handle.extmark_id then + return nil + end + + local ok, pos = pcall(vim.api.nvim_buf_get_extmark_by_id, handle.bufnr, ns, handle.extmark_id, { details = true }) + if not ok or not pos or #pos == 0 then + return nil + end + + local result = { line = pos[1] + 1 } + local details = pos[3] + if details and details.end_row then + result.end_line = details.end_row + 1 + end + + return result +end + +-- --------------------------------------------------------------------------- +-- Popup visibility helpers +-- --------------------------------------------------------------------------- + +local function hide_popup(handle) + if handle.popup_winid and vim.api.nvim_win_is_valid(handle.popup_winid) then + pcall(vim.api.nvim_win_close, handle.popup_winid, true) + end + handle.popup_winid = nil +end + +-- --------------------------------------------------------------------------- +-- Per-comment helpers (used by reconcile / capture_position_patches) +-- --------------------------------------------------------------------------- + +---@param tabpage integer +---@param session table +---@param comment codediff.comments.Comment +---@param comments codediff.comments.Comment[] +---@param tab table +local function reconcile_comment(tabpage, session, comment, comments, tab) + if not comment_visible_in_session(comment, session) then + if tab[comment.id] then + clear_handle(tabpage, comment.id) + end + return + end + + local target_bufnr = M.resolve_bufnr(session, comment) + if not target_bufnr then + if tab[comment.id] then + clear_handle(tabpage, comment.id) + end + return + end + + local handle = tab[comment.id] + + if handle and handle.bufnr ~= target_bufnr then + clear_handle(tabpage, comment.id) + handle = nil + end + + local is_new = not handle + if not handle then + ---@type codediff.ui.comments.Handle + handle = { bufnr = target_bufnr, extmark_id = 0 } + tab[comment.id] = handle + end + + -- Invalidate existing popup so it re-renders with fresh content (e.g. after edit). + if not is_new then + hide_popup(handle) + end + + if handle.extmark_id ~= 0 then + local pos = sync_handle_position(handle) + if not pos then + handle.extmark_id = 0 + if not render_extmark(comment, handle) then + clear_handle(tabpage, comment.id) + return + end + end + else + if not render_extmark(comment, handle) then + clear_handle(tabpage, comment.id) + return + end + end + + local sticky = model.is_sticky() + + if sticky then + local cmt = comment + local hdl = handle + vim.schedule(function() + if not hdl.extmark_id or hdl.extmark_id == 0 then + return + end + render_comment_popup(tabpage, cmt, hdl, comments, session) + end) + end +end + +---@param comment codediff.comments.Comment +---@param handle codediff.ui.comments.Handle? +---@param session table +---@param updates { id: integer, changes: codediff.comments.CommentPatch }[] +---@param stale_ids integer[] +local function capture_comment_patch(comment, handle, session, updates, stale_ids) + if not comment_visible_in_session(comment, session) then + return + end + + if not handle or not handle.extmark_id or handle.extmark_id == 0 then + stale_ids[#stale_ids + 1] = comment.id + return + end + + local pos = sync_handle_position(handle) + if not pos then + stale_ids[#stale_ids + 1] = comment.id + return + end + + local changed = false + ---@type codediff.comments.CommentPatch + local patch = {} + if pos.line ~= comment.line then + patch.line = pos.line + changed = true + end + if comment.end_line then + if pos.end_line and pos.end_line ~= comment.end_line then + patch.end_line = pos.end_line + changed = true + elseif not pos.end_line then + patch.end_line = false + changed = true + end + end + if changed then + updates[#updates + 1] = { id = comment.id, changes = patch } + end +end + +-- --------------------------------------------------------------------------- +-- Non-sticky helpers: show popup only when cursor is on a commented line +-- --------------------------------------------------------------------------- + +--- Hide all popups for a tab (used on BufLeave / WinLeave). +---@param tabpage integer +function M.hide_all_popups(tabpage) + local tab = handles[tabpage] + if not tab then + return + end + for _, handle in pairs(tab) do + hide_popup(handle) + end +end + +--- Update popup visibility for non-sticky mode based on the viewport. +--- Shows popups for comments whose line is visible on screen, hides the rest. +---@param tabpage integer +---@param session table +---@param comments codediff.comments.Comment[] +function M.update_viewport_popups(tabpage, session, comments) + local tab = handles[tabpage] + if not tab then + return + end + + -- Build a set of visible ranges per buffer from the tab's windows. + ---@type table + local visible = {} + for _, winid in ipairs(vim.api.nvim_tabpage_list_wins(tabpage)) do + if vim.api.nvim_win_is_valid(winid) then + local bufnr = vim.api.nvim_win_get_buf(winid) + local top = vim.fn.line("w0", winid) + local bot = vim.fn.line("w$", winid) + if not visible[bufnr] then + visible[bufnr] = {} + end + table.insert(visible[bufnr], { top = top, bot = bot }) + end + end + + for _, comment in ipairs(comments) do + local handle = tab[comment.id] + if not handle then + goto continue + end + + local in_view = false + local ranges = visible[handle.bufnr] + if ranges then + local cline = comment.line + for _, r in ipairs(ranges) do + if cline >= r.top and cline <= r.bot then + in_view = true + break + end + end + end + + if in_view then + if not handle.popup_winid or not vim.api.nvim_win_is_valid(handle.popup_winid) then + render_comment_popup(tabpage, comment, handle, comments, session) + end + else + hide_popup(handle) + end + + ::continue:: + end +end + +-- --------------------------------------------------------------------------- +-- Public API +-- --------------------------------------------------------------------------- + +--- Initialize highlights. Call once during setup. +function M.setup() + setup_comment_highlights() +end + +--- Reapply highlights after colorscheme change. +function M.refresh_highlights() + setup_comment_highlights() +end + +--- Get the winhighlight string for comment floats (used by editor too). +---@return string +function M.winhighlight() + return comment_winhighlight() +end + +--- Resolve which buffer a comment should render in for the current session. +--- Returns nil if the comment is not visible. +---@param session table +---@param comment codediff.comments.Comment +---@return integer? bufnr +function M.resolve_bufnr(session, comment) + if not comment_visible_in_session(comment, session) then + return nil + end + local side_meta = SIDE[comment.side] + local target_bufnr = side_meta and session[side_meta.bufnr_key] or nil + if not target_bufnr or not vim.api.nvim_buf_is_valid(target_bufnr) then + return nil + end + return target_bufnr +end + +--- Reconcile rendered state for a tab. Shows/hides/updates extmarks+popups +--- based on which comments are visible in the current session. +--- This is called on scroll/resize autocmds and after mutations. +---@param tabpage integer +---@param session table The lifecycle session object +---@param comments codediff.comments.Comment[] Current snapshot from store +function M.reconcile(tabpage, session, comments) + local tab = get_tab_handles(tabpage) + local live_ids = {} + + for _, comment in ipairs(comments) do + live_ids[comment.id] = true + reconcile_comment(tabpage, session, comment, comments, tab) + end + + for id, _ in pairs(tab) do + if not live_ids[id] then + clear_handle(tabpage, id) + end + end +end + +--- Capture position updates from extmarks. Returns pure data for the facade +--- to feed back into the store. Does NOT mutate the store. +---@param tabpage integer +---@param session table +---@param comments codediff.comments.Comment[] +---@return { updates: { id: integer, changes: codediff.comments.CommentPatch }[], stale_ids: integer[] } +function M.capture_position_patches(tabpage, session, comments) + local tab = handles[tabpage] or {} + local updates = {} + local stale_ids = {} + + for _, comment in ipairs(comments) do + capture_comment_patch(comment, tab[comment.id], session, updates, stale_ids) + end + + return { updates = updates, stale_ids = stale_ids } +end + +--- Clear all UI handles for a tab. +---@param tabpage integer +function M.clear_tab(tabpage) + local tab = handles[tabpage] + if not tab then + return + end + for id, _ in pairs(tab) do + clear_handle(tabpage, id) + end + handles[tabpage] = nil +end + +--- Clear all UI handles for all tabs. +function M.clear_all() + for tabpage, _ in pairs(handles) do + M.clear_tab(tabpage) + end + handles = {} +end + +--- Reset for tests. +function M._reset_for_tests() + M.clear_all() +end + +return M diff --git a/lua/codediff/ui/keymap_help.lua b/lua/codediff/ui/keymap_help.lua index c065b217..75a2bf49 100644 --- a/lua/codediff/ui/keymap_help.lua +++ b/lua/codediff/ui/keymap_help.lua @@ -61,6 +61,12 @@ local function build_sections(keymaps, is_explorer, is_history, is_conflict) table.insert(view_items, { km.unstage_hunk, "Unstage hunk under cursor" }) table.insert(view_items, { km.discard_hunk, "Discard hunk under cursor" }) end + table.insert(view_items, { km.comment_add, "Add pending comment" }) + table.insert(view_items, { km.comment_edit, "Edit pending comment" }) + table.insert(view_items, { km.comment_remove, "Remove pending comment" }) + table.insert(view_items, { km.comment_submit, "Submit pending comments" }) + table.insert(view_items, { km.comment_list, "List pending comments" }) + table.insert(view_items, { km.comment_clear, "Clear pending comments" }) table.insert(view_items, { km.hunk_textobject, "Hunk textobject (visual/operator)" }) table.insert(view_items, { km.show_help, "Toggle this help" }) table.insert(sections, section("VIEW", view_items)) diff --git a/lua/codediff/ui/lib/float.lua b/lua/codediff/ui/lib/float.lua new file mode 100644 index 00000000..047f1c98 --- /dev/null +++ b/lua/codediff/ui/lib/float.lua @@ -0,0 +1,85 @@ +-- Shared floating window helpers for CodeDiff UI modules. +local M = {} + +---@param border any +---@return boolean +function M.border_is_none(border) + if border == nil then + return false + end + + if type(border) == "string" then + return border == "" or border:lower() == "none" + end + + if type(border) == "table" and type(border.style) == "string" then + return border.style:lower() == "none" + end + + return false +end + +---@param opts? { filetype?: string } +---@return integer bufnr +function M.create_scratch_buf(opts) + opts = opts or {} + local bufnr = vim.api.nvim_create_buf(false, true) + vim.bo[bufnr].buftype = "nofile" + vim.bo[bufnr].bufhidden = "wipe" + vim.bo[bufnr].swapfile = false + if opts.filetype then + vim.bo[bufnr].filetype = opts.filetype + end + return bufnr +end + +---@param winid integer +---@param winhighlight string +function M.set_float_win_options(winid, winhighlight) + if not winid or not vim.api.nvim_win_is_valid(winid) then + return + end + vim.wo[winid].wrap = false + vim.wo[winid].cursorline = false + vim.wo[winid].number = false + vim.wo[winid].relativenumber = false + vim.wo[winid].winhighlight = winhighlight +end + +---@param win_config table +---@param border any +---@param title string? +---@param title_pos string? +---@param footer string? +---@param footer_pos string? +function M.apply_title_footer(win_config, border, title, title_pos, footer, footer_pos) + if M.border_is_none(border) then + return + end + if vim.fn.has("nvim-0.9") ~= 1 then + return + end + if title then + win_config.title = title + win_config.title_pos = title_pos or "left" + end + if footer and vim.fn.has("nvim-0.10") == 1 then + win_config.footer = footer + win_config.footer_pos = footer_pos or "left" + end +end + +---@param existing_winid integer? +---@param bufnr integer +---@param enter boolean +---@param win_config table +---@return integer winid +function M.open_or_reconfigure(existing_winid, bufnr, enter, win_config) + if existing_winid and vim.api.nvim_win_is_valid(existing_winid) then + pcall(vim.api.nvim_win_set_config, existing_winid, win_config) + return existing_winid + end + return vim.api.nvim_open_win(bufnr, enter, win_config) +end + +return M diff --git a/lua/codediff/ui/view/keymaps.lua b/lua/codediff/ui/view/keymaps.lua index 546bef0c..0ed23de5 100644 --- a/lua/codediff/ui/view/keymaps.lua +++ b/lua/codediff/ui/view/keymaps.lua @@ -591,6 +591,43 @@ function M.setup_all_keymaps(tabpage, original_bufnr, modified_bufnr, is_explore end, { desc = "Toggle diff layout" }) end + if keymaps.comment_add then + lifecycle.set_tab_keymap(tabpage, "n", keymaps.comment_add, function() + require("codediff.ui.comments").open_add_editor() + end, { desc = "Add pending comment" }) + lifecycle.set_tab_keymap(tabpage, "x", keymaps.comment_add, function() + -- Exit visual mode so ' marks are set, then open editor with range + local esc = vim.api.nvim_replace_termcodes("", true, false, true) + vim.api.nvim_feedkeys(esc, "nx", false) + require("codediff.ui.comments").open_add_editor({ visual = true }) + end, { desc = "Add pending comment (visual)" }) + end + if keymaps.comment_edit then + lifecycle.set_tab_keymap(tabpage, "n", keymaps.comment_edit, function() + require("codediff.ui.comments").open_edit_editor() + end, { desc = "Edit pending comment" }) + end + if keymaps.comment_remove then + lifecycle.set_tab_keymap(tabpage, "n", keymaps.comment_remove, function() + require("codediff.ui.comments").remove_comment() + end, { desc = "Remove pending comment" }) + end + if keymaps.comment_submit then + lifecycle.set_tab_keymap(tabpage, "n", keymaps.comment_submit, function() + require("codediff.ui.comments").submit_comments() + end, { desc = "Submit pending comments" }) + end + if keymaps.comment_list then + lifecycle.set_tab_keymap(tabpage, "n", keymaps.comment_list, function() + require("codediff.ui.comments").list_comments() + end, { desc = "List pending comments" }) + end + if keymaps.comment_clear then + lifecycle.set_tab_keymap(tabpage, "n", keymaps.comment_clear, function() + require("codediff.ui.comments").clear_comments() + end, { desc = "Clear pending comments" }) + end + -- Toggle stage/unstage (- key) - only in explorer mode -- Support legacy config: keymaps.explorer.toggle_stage (deprecated) if is_explorer_mode then diff --git a/plugin/codediff.lua b/plugin/codediff.lua index 74acd25b..6080b369 100644 --- a/plugin/codediff.lua +++ b/plugin/codediff.lua @@ -9,9 +9,11 @@ vim.g.loaded_codediff = 1 -- Lightweight startup: highlights (~0.3ms) + virtual file scheme (~0.1ms) local highlights = require("codediff.ui.highlights") local virtual_file = require('codediff.core.virtual_file') +local comments = require("codediff.ui.comments") virtual_file.setup() highlights.setup() +comments.setup() -- Re-apply highlights on ColorScheme change vim.api.nvim_create_autocmd("ColorScheme", { @@ -83,6 +85,14 @@ local function complete_codediff(arg_lead, cmd_line, _) return vim.fn.getcompletion(arg_lead, "file") end + if first_arg == "comments" or first_arg == "comment" then + if #args <= 3 then + local result = complete_flags({ "add", "edit", "remove", "list", "submit", "clear" }, arg_lead) + return result or {} + end + return {} + end + -- Flag completion for subcommands if arg_lead:match("^%-") then if first_arg == "history" then @@ -99,33 +109,25 @@ local function complete_codediff(arg_lead, cmd_line, _) if #args == 2 and arg_lead ~= "" then local cwd = vim.fn.getcwd() local git_root = git.get_git_root_sync(cwd) - local rev_candidates = get_cached_rev_candidates(git_root) + local rev_candidates = get_cached_rev_candidates(git_root) or {} local filtered = {} - -- Check if user is typing a triple-dot pattern (e.g., "main...") local base_rev = arg_lead:match("^(.+)%.%.%.$") if base_rev then - -- User typed "main...", suggest completing with refs or leave as-is - if rev_candidates then - for _, candidate in ipairs(rev_candidates) do - table.insert(filtered, base_rev .. "..." .. candidate) - end + for _, candidate in ipairs(rev_candidates) do + table.insert(filtered, base_rev .. "..." .. candidate) end - -- Also include the bare triple-dot (compares to working tree) table.insert(filtered, 1, arg_lead) return filtered end - -- Normal completion: match refs and also suggest triple-dot variants - if rev_candidates then - for _, candidate in ipairs(rev_candidates) do - if candidate:find(arg_lead, 1, true) == 1 then - table.insert(filtered, candidate) - -- Also suggest the merge-base variant - table.insert(filtered, candidate .. "...") - end + for _, candidate in ipairs(rev_candidates) do + if candidate:find(arg_lead, 1, true) == 1 then + table.insert(filtered, candidate) + table.insert(filtered, candidate .. "...") end end + if #filtered > 0 then return filtered end @@ -142,5 +144,5 @@ end, { bang = true, range = true, complete = complete_codediff, - desc = "VSCode-style diff view: :CodeDiff [] | merge | file | install" + desc = "VSCode-style diff view: :CodeDiff [] | merge | file | history | comments | install" }) diff --git a/tests/comments_spec.lua b/tests/comments_spec.lua new file mode 100644 index 00000000..cae5c5c4 --- /dev/null +++ b/tests/comments_spec.lua @@ -0,0 +1,309 @@ +local comments = require("codediff.ui.comments") +local session_mod = require("codediff.ui.lifecycle.session") +local config = require("codediff.config") + +local function has_buffer_map(bufnr, mode, lhs) + for _, map in ipairs(vim.api.nvim_buf_get_keymap(bufnr, mode)) do + if map.lhs == lhs then + return true + end + end + return false +end + +describe("CodeDiff Comments", function() + local tabpage + local bufnr + local active_diffs + local original_config + + before_each(function() + tabpage = vim.api.nvim_get_current_tabpage() + bufnr = vim.api.nvim_get_current_buf() + active_diffs = session_mod.get_active_diffs() + original_config = vim.deepcopy(config.options) + config.options = vim.deepcopy(config.defaults) + + active_diffs[tabpage] = { + mode = "explorer", + original_revision = "abc123", + modified_revision = "def456", + original_path = "a.lua", + modified_path = "a.lua", + original_bufnr = bufnr, + modified_bufnr = bufnr, + } + + comments._reset_for_tests() + end) + + after_each(function() + comments._reset_for_tests() + active_diffs[tabpage] = nil + config.options = original_config + end) + + it("submit clears extmarks and in-memory comments", function() + local added = comments.add_comment("looks good") + assert.is_true(added) + + local marks_before = vim.api.nvim_buf_get_extmarks(bufnr, vim.api.nvim_create_namespace("codediff-comments"), 0, -1, {}) + assert.is_true(#marks_before > 0, "Expected pending comment extmarks before submit") + + local submitted = comments.submit_comments() + assert.is_true(submitted) + + local marks_after = vim.api.nvim_buf_get_extmarks(bufnr, vim.api.nvim_create_namespace("codediff-comments"), 0, -1, {}) + assert.equals(0, #marks_after, "Expected pending comment extmarks to be cleared after submit") + assert.equals(0, #comments.get_comments(tabpage), "Expected in-memory comments to be cleared after submit") + end) + + it("submit uses configured hook and still clears UI state", function() + local hook_calls = 0 + local last_payload + comments.set_submit_hook(function(payload, submitted_comments, context) + hook_calls = hook_calls + 1 + last_payload = payload + assert.equals(1, #submitted_comments) + assert.equals("explorer", context.mode) + return true + end) + + assert.is_true(comments.add_comment("hook path")) + assert.is_true(comments.submit_comments()) + + assert.equals(1, hook_calls) + assert.is_true(last_payload:find("hook path", 1, true) ~= nil) + assert.equals(0, #comments.get_comments(tabpage)) + end) + + it("removes comment at cursor", function() + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + assert.is_true(comments.add_comment("typo here")) + + local marks_before = vim.api.nvim_buf_get_extmarks(bufnr, vim.api.nvim_create_namespace("codediff-comments"), 0, -1, {}) + assert.equals(1, #marks_before) + + assert.is_true(comments.remove_comment()) + + local marks_after = vim.api.nvim_buf_get_extmarks(bufnr, vim.api.nvim_create_namespace("codediff-comments"), 0, -1, {}) + assert.equals(0, #marks_after) + assert.equals(0, #comments.get_comments(tabpage)) + end) + + it("removes comment by id", function() + assert.is_true(comments.add_comment("first")) + assert.is_true(comments.add_comment("second")) + + local pending = comments.get_comments(tabpage) + assert.equals(2, #pending) + + local first_id = pending[1].id + assert.is_true(comments.remove_comment(first_id)) + + local after = comments.get_comments(tabpage) + assert.equals(1, #after) + assert.equals("second", after[1].text) + end) + + it("edits comment by id", function() + assert.is_true(comments.add_comment("original")) + + local pending = comments.get_comments(tabpage) + local comment_id = pending[1].id + + assert.is_true(comments.edit_comment(comment_id, "updated")) + + local after = comments.get_comments(tabpage) + assert.equals(1, #after) + assert.equals("updated", after[1].text) + end) + + it("opens edit editor at the end of existing comment text", function() + assert.is_true(comments.add_comment("first line\nsecond line")) + local pending = comments.get_comments(tabpage) + assert.equals(1, #pending) + + assert.is_true(comments.open_edit_editor(pending[1].id)) + + local editor_bufnr = vim.api.nvim_get_current_buf() + local mode = vim.api.nvim_get_mode().mode + assert.equals("i", mode:sub(1, 1)) + assert.is_true(has_buffer_map(editor_bufnr, "n", "")) + assert.is_true(has_buffer_map(editor_bufnr, "n", "q")) + + local cursor = vim.api.nvim_win_get_cursor(0) + assert.equals(2, cursor[1]) + assert.equals(#"second line", cursor[2]) + end) + + it("supports normal editor mode with normal-first editing", function() + config.options.comments.ui.editor_mode = "normal" + + assert.is_true(comments.add_comment("alpha")) + local pending = comments.get_comments(tabpage) + assert.equals(1, #pending) + + assert.is_true(comments.open_edit_editor(pending[1].id)) + + local editor_bufnr = vim.api.nvim_get_current_buf() + local mode = vim.api.nvim_get_mode().mode + assert.equals("n", mode:sub(1, 1)) + assert.is_true(has_buffer_map(editor_bufnr, "n", "")) + assert.is_true(has_buffer_map(editor_bufnr, "n", "q")) + assert.is_false(has_buffer_map(editor_bufnr, "i", "")) + assert.is_false(has_buffer_map(editor_bufnr, "i", "q")) + end) + + it("lists pending comments in quickfix", function() + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { "line1", "line2", "line3" }) + + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + assert.is_true(comments.add_comment("first pending note")) + + vim.api.nvim_win_set_cursor(0, { 3, 0 }) + assert.is_true(comments.add_comment("third pending note")) + + assert.is_true(comments.list_comments()) + + local qf = vim.fn.getqflist({ title = 1, items = 1 }) + assert.equals("CodeDiff pending comments (2)", qf.title) + assert.equals(2, #qf.items) + assert.equals(bufnr, qf.items[1].bufnr) + assert.equals(1, qf.items[1].lnum) + assert.equals("I", qf.items[1].type) + assert.is_true(qf.items[1].text:find("c1 [left] a.lua:1 first pending note", 1, true) ~= nil) + assert.equals(bufnr, qf.items[2].bufnr) + assert.equals(3, qf.items[2].lnum) + assert.equals("I", qf.items[2].type) + assert.is_true(qf.items[2].text:find("c2 [left] a.lua:3 third pending note", 1, true) ~= nil) + + pcall(vim.cmd, "cclose") + end) + + it("submit payload includes structured comment identifiers", function() + local payload = nil + comments.set_submit_hook(function(formatted) + payload = formatted + return true + end) + + assert.is_true(comments.add_comment("payload shape")) + assert.is_true(comments.submit_comments()) + + assert.is_true(payload:find("CodeDiff review (1 comment)", 1, true) ~= nil) + assert.is_true(payload:find("a.lua:1 (old)", 1, true) ~= nil) + assert.is_true(payload:find("payload shape", 1, true) ~= nil) + end) + + it("submit syncs line numbers from extmarks", function() + local submitted_comments = nil + comments.set_submit_hook(function(_, pending) + submitted_comments = pending + return true + end) + + assert.is_true(comments.add_comment("line shift")) + vim.api.nvim_buf_set_lines(bufnr, 0, 0, false, { "new first line" }) + + assert.is_true(comments.submit_comments()) + assert.equals(1, #submitted_comments) + assert.equals(2, submitted_comments[1].line) + end) + + it("submit keeps comments hidden for other files in the same session", function() + local submitted_comments = nil + comments.set_submit_hook(function(_, pending) + submitted_comments = pending + return true + end) + + assert.is_true(comments.add_comment("from file A")) + + -- Simulate explorer/history switching to another file in the same pane. + -- Hidden comments lose extmarks temporarily but must still be submitted. + active_diffs[tabpage].original_path = "b.lua" + + assert.is_true(comments.submit_comments()) + assert.equals(1, #submitted_comments) + assert.equals("from file A", submitted_comments[1].text) + end) + + it("does not clear comments when hook returns false", function() + comments.set_submit_hook(function() + return false + end) + + assert.is_true(comments.add_comment("keep me")) + assert.is_false(comments.submit_comments()) + assert.equals(1, #comments.get_comments(tabpage)) + end) + + it("adds ranged comment with explicit line1/line2", function() + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { "line1", "line2", "line3", "line4", "line5" }) + assert.is_true(comments.add_comment("range note", 2, 4)) + + local pending = comments.get_comments(tabpage) + assert.equals(1, #pending) + assert.equals(2, pending[1].line) + assert.equals(4, pending[1].end_line) + assert.equals("range note", pending[1].text) + end) + + it("single-line range does not set end_line", function() + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { "line1", "line2" }) + assert.is_true(comments.add_comment("single", 3, 3)) + + local pending = comments.get_comments(tabpage) + assert.equals(1, #pending) + assert.is_nil(pending[1].end_line) + end) + + it("ranged comment appears in quickfix with range text", function() + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { "a", "b", "c", "d", "e" }) + assert.is_true(comments.add_comment("range qf", 2, 5)) + + assert.is_true(comments.list_comments()) + + local qf = vim.fn.getqflist({ title = 1, items = 1 }) + assert.equals(1, #qf.items) + assert.equals(2, qf.items[1].lnum) + assert.is_true(qf.items[1].text:find("a.lua:2-5", 1, true) ~= nil) + + pcall(vim.cmd, "cclose") + end) + + it("submit payload shows range format for ranged comments", function() + local payload = nil + comments.set_submit_hook(function(formatted) + payload = formatted + return true + end) + + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { "a", "b", "c", "d" }) + assert.is_true(comments.add_comment("range payload", 1, 3)) + assert.is_true(comments.submit_comments()) + + assert.is_true(payload:find("a.lua:1-3 (old)", 1, true) ~= nil) + assert.is_true(payload:find("range payload", 1, true) ~= nil) + end) + + it("submit syncs ranged comment end_line from extmarks", function() + local submitted_comments = nil + comments.set_submit_hook(function(_, pending) + submitted_comments = pending + return true + end) + + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { "a", "b", "c", "d" }) + assert.is_true(comments.add_comment("shift range", 2, 4)) + + -- Insert a line before the range, shifting both start and end + vim.api.nvim_buf_set_lines(bufnr, 0, 0, false, { "new first" }) + + assert.is_true(comments.submit_comments()) + assert.equals(1, #submitted_comments) + assert.equals(3, submitted_comments[1].line) + assert.equals(5, submitted_comments[1].end_line) + end) +end) diff --git a/tests/completion_spec.lua b/tests/completion_spec.lua index cd4416dd..91406ed3 100644 --- a/tests/completion_spec.lua +++ b/tests/completion_spec.lua @@ -1,7 +1,7 @@ -- Test: Command completion -- Validates :CodeDiff command completion with dynamic git refs -local git = require('codediff.core.git') +local git = require("codediff.core.git") local commands = require("codediff.commands") describe("Command Completion", function() @@ -103,6 +103,7 @@ describe("Command Completion", function() it("Contains expected subcommands", function() assert.is_true(vim.tbl_contains(commands.SUBCOMMANDS, "file"), "Should include 'file'") + assert.is_true(vim.tbl_contains(commands.SUBCOMMANDS, "comments"), "Should include 'comments'") assert.is_true(vim.tbl_contains(commands.SUBCOMMANDS, "install"), "Should include 'install'") end) end) diff --git a/tests/snapshot_cache_spec.lua b/tests/snapshot_cache_spec.lua new file mode 100644 index 00000000..30d74e70 --- /dev/null +++ b/tests/snapshot_cache_spec.lua @@ -0,0 +1,111 @@ +local snapshot_cache = require("codediff.comments.snapshot_cache") + +describe("CodeDiff Snapshot Cache", function() + local tmpdir + + before_each(function() + snapshot_cache._reset_for_tests() + tmpdir = vim.fn.tempname() + vim.fn.mkdir(tmpdir, "p") + snapshot_cache._set_dir_for_tests(tmpdir) + end) + + after_each(function() + snapshot_cache._reset_for_tests() + vim.fn.delete(tmpdir, "rf") + end) + + local function make_comments() + return { + { id = 1, side = "left", path = "a.lua", line = 10, text = "looks good" }, + { id = 2, side = "right", path = "b.lua", line = 20, end_line = 25, text = "needs work" }, + } + end + + describe("session_id", function() + it("produces stable deterministic hashes", function() + local id1 = snapshot_cache.session_id("/repo", "abc123", "def456") + local id2 = snapshot_cache.session_id("/repo", "abc123", "def456") + assert.equals(id1, id2) + assert.is_string(id1) + assert.is_true(#id1 > 0) + end) + + it("produces different hashes for different inputs", function() + local id1 = snapshot_cache.session_id("/repo", "abc123", "def456") + local id2 = snapshot_cache.session_id("/repo", "abc123", "fff999") + local id3 = snapshot_cache.session_id("/other", "abc123", "def456") + assert.is_not.equals(id1, id2) + assert.is_not.equals(id1, id3) + end) + end) + + describe("save + restore", function() + it("round-trips comments in memory", function() + local sid = snapshot_cache.session_id("/repo", "a", "b") + local comments = make_comments() + snapshot_cache.save(sid, comments) + + local restored = snapshot_cache.restore(sid) + assert.is_not_nil(restored) + assert.equals(2, #restored) + assert.equals("looks good", restored[1].text) + assert.equals("right", restored[2].side) + assert.equals(25, restored[2].end_line) + end) + + it("returns nil for unknown session_id", function() + assert.is_nil(snapshot_cache.restore("nonexistent")) + end) + + it("round-trips comments from disk after memory reset", function() + local sid = snapshot_cache.session_id("/repo", "a", "b") + local comments = make_comments() + snapshot_cache.save(sid, comments) + + snapshot_cache._reset_for_tests() + + local restored = snapshot_cache.restore(sid) + assert.is_not_nil(restored) + assert.equals(2, #restored) + assert.equals("looks good", restored[1].text) + assert.equals(20, restored[2].line) + end) + + it("saves and restores an empty comments list", function() + local sid = snapshot_cache.session_id("/repo", "a", "b") + snapshot_cache.save(sid, {}) + + local restored = snapshot_cache.restore(sid) + -- Accept either empty table or nil + if restored ~= nil then + assert.equals(0, #restored) + end + end) + end) + + describe("remove", function() + it("causes subsequent restore to return nil", function() + local sid = snapshot_cache.session_id("/repo", "a", "b") + snapshot_cache.save(sid, make_comments()) + + snapshot_cache.remove(sid) + + assert.is_nil(snapshot_cache.restore(sid)) + end) + + it("deletes the disk file", function() + local sid = snapshot_cache.session_id("/repo", "a", "b") + snapshot_cache.save(sid, make_comments()) + + -- Verify a file was written + local files_before = vim.fn.glob(tmpdir .. "/*", false, true) + assert.is_true(#files_before > 0, "Expected disk file after save") + + snapshot_cache.remove(sid) + + local files_after = vim.fn.glob(tmpdir .. "/*", false, true) + assert.equals(0, #files_after, "Expected disk file to be removed") + end) + end) +end)