diff --git a/README.md b/README.md index 74f198d..95d2bff 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ Add comments to any line or code block, export as structured Markdown, and paste - 👁️ **Smart navigation** - Jump between comments, view at cursor - ✏️ **Live preview** - See and edit all your comments in one place - 🔍 **Find comments** - List all with `rl` using Telescope, fzf, or quickfix +- 💬 **Thread discussions** - Reply to comments and resolve threads ## 📦 Installation @@ -268,6 +269,8 @@ end | `:CodeReviewCopy` | `ry` | Copy review to clipboard | | `:CodeReviewClear` | `rx` | Clear all review comments | | `:CodeReviewDeleteComment` | `rd` | Delete comment at cursor position | +| `:CodeReviewResolveThread` | - | Mark current thread as resolved | +| `:CodeReviewReopenThread` | - | Reopen a resolved thread | ### Visual Indicators @@ -305,6 +308,15 @@ The preview buffer is fully editable. You can: - Save with `:w` to update the review - Close with `q` +### Thread Discussions + +Comments automatically create discussion threads. You can: + +- **Reply to comments**: Add comments on the same line to create a thread +- **Resolve threads**: Use `:CodeReviewResolveThread` when discussion is complete +- **Reopen threads**: Use `:CodeReviewReopenThread` if more discussion is needed +- Thread status is displayed in the preview (`[open]` or `[resolved]`) + ### Comment List Picker Comment List Picker diff --git a/lua/code-review/comment.lua b/lua/code-review/comment.lua index 31514c0..4f4eef0 100644 --- a/lua/code-review/comment.lua +++ b/lua/code-review/comment.lua @@ -34,7 +34,7 @@ function M.add(context_lines) return end - -- Create comment data + -- Always create a new comment (new thread) local comment_data = { file = context.file, line_start = context.line_start, @@ -48,7 +48,7 @@ function M.add(context_lines) -- Copy to clipboard if enabled local config = require("code-review.config") - if config.get("comment.auto_copy_on_add") then + if config.get("comment.auto_copy_on_add") and comment_data then -- Format the comment with full context (like rs shows) local formatted_lines = M.format_as_markdown(comment_data, true, false) local formatted_text = table.concat(formatted_lines, "\n") @@ -149,26 +149,48 @@ end add_virtual_text = function(bufnr, comments) local config = require("code-review.config").get("ui.virtual_text") - -- Group comments by line for virtual text - local comments_by_line = {} + -- Group comments by line and thread for virtual text + local threads_by_line = {} for _, comment in ipairs(comments) do -- Only show on first line of range local line = comment.line_start - if not comments_by_line[line] then - comments_by_line[line] = {} + if not threads_by_line[line] then + threads_by_line[line] = {} end - table.insert(comments_by_line[line], comment) + + -- Group by thread + local thread_id = comment.thread_id or comment.id + if not threads_by_line[line][thread_id] then + threads_by_line[line][thread_id] = {} + end + table.insert(threads_by_line[line][thread_id], comment) end -- Add virtual text - for line, line_comments in pairs(comments_by_line) do + for line, line_threads in pairs(threads_by_line) do local text = config.prefix - if #line_comments > 1 then - -- Multiple comments on same line - text = text .. string.format("(%d comments)", #line_comments) + local thread_count = vim.tbl_count(line_threads) + + if thread_count > 1 then + -- Multiple threads on same line + text = text .. string.format("(%d threads)", thread_count) else - -- Single comment - show first line of comment - local first_line = line_comments[1].comment:match("^[^\n]*") or line_comments[1].comment + -- Single thread - find the latest comment + local _, thread_comments = next(line_threads) + + -- Find the latest comment (last in thread) + local latest_comment = thread_comments[#thread_comments] + + -- If no timestamp, assume comments are in chronological order + if thread_comments[1].timestamp then + -- Sort by timestamp to find the latest + table.sort(thread_comments, function(a, b) + return (a.timestamp or 0) < (b.timestamp or 0) + end) + latest_comment = thread_comments[#thread_comments] + end + local first_line = latest_comment.comment:match("^[^\n]*") or latest_comment.comment + -- Truncate if too long if #first_line > 40 then first_line = first_line:sub(1, 37) .. "..." diff --git a/lua/code-review/config.lua b/lua/code-review/config.lua index b225cf4..0d62dee 100644 --- a/lua/code-review/config.lua +++ b/lua/code-review/config.lua @@ -110,6 +110,16 @@ local defaults = { mode = "n", key = "rd", }, + -- Reply to comment at cursor + reply_comment = { + mode = "n", + key = "rr", + }, + -- Resolve thread at cursor + resolve_thread = { + mode = "n", + key = "ro", + }, }, -- Integration settings integrations = { diff --git a/lua/code-review/init.lua b/lua/code-review/init.lua index 85368e7..f2846f4 100644 --- a/lua/code-review/init.lua +++ b/lua/code-review/init.lua @@ -54,6 +54,28 @@ function M.setup(opts) M.delete_comment_at_cursor() end, { desc = "Delete comment at cursor position" }) + vim.api.nvim_create_user_command("CodeReviewReply", function() + M.reply_to_comment_at_cursor() + end, { desc = "Reply to comment at cursor position" }) + + vim.api.nvim_create_user_command("CodeReviewResolve", function() + M.resolve_thread_at_cursor() + end, { desc = "Resolve thread at cursor position" }) + + vim.api.nvim_create_user_command("CodeReviewSetStatus", function(args) + if args.args == "" then + vim.notify("Usage: :CodeReviewSetStatus ", vim.log.levels.ERROR) + return + end + M.set_review_status(args.args) + end, { + desc = "Set review status", + nargs = 1, + complete = function() + return { "draft", "open", "resolved", "closed" } + end, + }) + -- Setup keymaps if enabled local keymaps = config.get("keymaps") if keymaps then @@ -79,6 +101,8 @@ function M.setup(opts) show_comment = "Show comment at cursor", list_comments = "List all comments", delete_comment = "Delete comment at cursor", + reply_comment = "Reply to comment at cursor", + resolve_thread = "Resolve thread at cursor", } local func = { @@ -92,6 +116,8 @@ function M.setup(opts) show_comment = M.show_comment_at_cursor, list_comments = M.list_comments, delete_comment = M.delete_comment_at_cursor, + reply_comment = M.reply_to_comment_at_cursor, + resolve_thread = M.resolve_thread_at_cursor, } if func[action] then @@ -208,6 +234,149 @@ function M.list_comments() require("code-review.list").list_comments() end +--- Reply to comment at cursor position +function M.reply_to_comment_at_cursor() + local bufnr = vim.api.nvim_get_current_buf() + local file = utils.normalize_path(vim.api.nvim_buf_get_name(bufnr)) + local row = vim.api.nvim_win_get_cursor(0)[1] + + -- Find comments for current line + local line_comments = state.get_comments_at_location(file, row) + + if #line_comments == 0 then + vim.notify("No comment at cursor position", vim.log.levels.WARN) + return + end + + -- Group comments by thread + local threads = {} + for _, c in ipairs(line_comments) do + local thread_id = c.thread_id or c.id + if not threads[thread_id] then + threads[thread_id] = { + id = thread_id, + root_comment = nil, + comments = {}, + } + end + table.insert(threads[thread_id].comments, c) + -- Track root comment + if not c.parent_id then + threads[thread_id].root_comment = c + end + end + + -- Select thread if multiple threads exist + local thread_count = vim.tbl_count(threads) + + if thread_count == 1 then + -- Single thread case + local selected_thread = threads[next(threads)] + local comment_to_reply = selected_thread.root_comment or selected_thread.comments[1] + + -- Show input UI for reply with the same context as the original comment + ui.show_comment_input(function(reply_text) + if reply_text and reply_text ~= "" then + state.add_reply(comment_to_reply.id, reply_text) + vim.notify("Reply added", vim.log.levels.INFO) + end + end, { + file = comment_to_reply.file, + line_start = comment_to_reply.line_start, + line_end = comment_to_reply.line_end, + lines = comment_to_reply.context_lines or {}, + }, " Reply to Comment (C-CR to submit) ") + else + -- Create thread selection items + local thread_items = {} + for _, thread in pairs(threads) do + -- Always use the first comment (oldest) for preview + local first_comment = thread.comments[1] + local preview = first_comment.comment:sub(1, 50) + if #first_comment.comment > 50 then + preview = preview .. "..." + end + local item = { + display = string.format("%d. %s (%d comments)", #thread_items + 1, preview, #thread.comments), + thread = thread, + } + table.insert(thread_items, item) + end + + -- Show thread selection + vim.ui.select(thread_items, { + prompt = "Select thread to reply to:", + format_item = function(item) + return item.display + end, + }, function(choice) + if not choice then + return + end + + local selected_thread = choice.thread + + -- Continue with reply process inside callback + local comment_to_reply = selected_thread.root_comment or selected_thread.comments[1] + + -- Show input UI for reply with the same context as the original comment + ui.show_comment_input(function(reply_text) + if reply_text and reply_text ~= "" then + state.add_reply(comment_to_reply.id, reply_text) + vim.notify("Reply added", vim.log.levels.INFO) + end + end, { + file = comment_to_reply.file, + line_start = comment_to_reply.line_start, + line_end = comment_to_reply.line_end, + lines = comment_to_reply.context_lines or {}, + }, " Reply to Comment (C-CR to submit) ") + end) + end +end + +--- Resolve thread at cursor position +function M.resolve_thread_at_cursor() + local bufnr = vim.api.nvim_get_current_buf() + local file = utils.normalize_path(vim.api.nvim_buf_get_name(bufnr)) + local row = vim.api.nvim_win_get_cursor(0)[1] + + -- Find comments for current line + local line_comments = state.get_comments_at_location(file, row) + + if #line_comments == 0 then + vim.notify("No comment at cursor position", vim.log.levels.WARN) + return + end + + -- Get unique threads + local threads = {} + for _, c in ipairs(line_comments) do + if c.thread_id then + threads[c.thread_id] = true + end + end + + local thread_count = vim.tbl_count(threads) + if thread_count == 0 then + vim.notify("No thread found", vim.log.levels.WARN) + return + elseif thread_count == 1 then + local thread_id = next(threads) + state.resolve_thread(thread_id) + else + -- Multiple threads, let user choose + vim.notify("Multiple threads at this location", vim.log.levels.WARN) + end +end + +--- Set review status +---@param status string New status +function M.set_review_status(status) + local review = require("code-review.review") + review.update_status(status) +end + --- Delete comment at cursor position function M.delete_comment_at_cursor() local bufnr = vim.api.nvim_get_current_buf() diff --git a/lua/code-review/list.lua b/lua/code-review/list.lua index 80c8bd0..748094a 100644 --- a/lua/code-review/list.lua +++ b/lua/code-review/list.lua @@ -32,19 +32,56 @@ function M.list_with_quickfix() return end - -- Sort by file and line - local sorted_comments = vim.deepcopy(comments) - table.sort(sorted_comments, function(a, b) - if a.file ~= b.file then - return a.file < b.file + -- Build thread tree + local thread = require("code-review.thread") + local threads = thread.build_thread_tree(comments) + + -- Get review session for thread status + local review_session = state.get_review_session() + local thread_statuses = review_session and review_session.threads or {} + + -- Sort threads by file and line + local sorted_threads = {} + for _, thread_data in pairs(threads) do + table.insert(sorted_threads, thread_data) + end + table.sort(sorted_threads, function(a, b) + local a_root = a.root_comment + local b_root = b.root_comment + if a_root.file ~= b_root.file then + return a_root.file < b_root.file end - return a.line_start < b.line_start + return a_root.line_start < b_root.line_start end) - -- Convert to quickfix items + -- Convert to quickfix items with thread grouping local qf_items = {} - for _, comment in ipairs(sorted_comments) do - table.insert(qf_items, comment_to_qf_item(comment)) + for _, thread_data in ipairs(sorted_threads) do + local thread_status = thread_statuses[thread_data.id] + local status_indicator = "" + if thread_status then + if thread_status.status == "resolved" then + status_indicator = "[✓] " + elseif thread_status.status == "outdated" then + status_indicator = "[~] " + else + status_indicator = "[•] " + end + end + + -- Add root comment with thread indicator + local root_item = comment_to_qf_item(thread_data.root_comment) + root_item.text = status_indicator .. "THREAD: " .. root_item.text + table.insert(qf_items, root_item) + + -- Add replies in linear order + if thread_data.replies then + for _, reply in ipairs(thread_data.replies) do + local reply_item = comment_to_qf_item(reply) + reply_item.text = " └─ " .. reply_item.text + table.insert(qf_items, reply_item) + end + end end -- Set quickfix list diff --git a/lua/code-review/state.lua b/lua/code-review/state.lua index 6f204ca..8079b55 100644 --- a/lua/code-review/state.lua +++ b/lua/code-review/state.lua @@ -65,7 +65,33 @@ end --- Add a comment to the session ---@param comment_data table Comment data function M.add_comment(comment_data) - local id = get_storage().add(comment_data) + local storage_backend = get_storage() + + -- Prepare metadata for root comments + if not comment_data.parent_id then + comment_data.author = comment_data.author or vim.fn.expand("$USER") + comment_data.replies = {} + end + + -- Add comment to storage (this will generate the real ID) + local id = storage_backend.add(comment_data) + + -- For root comments, set thread_id and thread_status + if not comment_data.parent_id then + local thread_id = id .. "_thread" + + -- Get the comment and update it with thread info + local comment = storage_backend.get(id) + if comment then + comment.thread_id = thread_id + comment.thread_status = "open" + + -- Re-save with thread info + storage_backend.delete(id) + storage_backend.add(comment) + end + end + M.refresh_ui() return id end @@ -156,6 +182,85 @@ function M.get_comments_at_location(file, line) return get_storage().get_at_location(file, line) end +--- Add a reply to a comment +---@param parent_id string Parent comment ID (can be any comment in the thread) +---@param reply_text string Reply text +---@return string|nil reply_id +function M.add_reply(parent_id, reply_text) + local parent = M.get_comment(parent_id) + if not parent then + vim.notify("Parent comment not found", vim.log.levels.ERROR) + return nil + end + + local thread = require("code-review.thread") + + -- Always create a reply to the thread, not nested under specific comment + local reply = thread.create_reply(parent, reply_text) + + -- Add the reply + local id = get_storage().add(reply) + + M.refresh_ui() + return id +end + +--- Get all comments in a thread +---@param thread_id string Thread ID +---@return table[] comments +function M.get_thread_comments(thread_id) + local all_comments = M.get_comments() + local thread = require("code-review.thread") + return thread.get_thread_comments(thread_id, all_comments) +end + +--- Resolve a thread +---@param thread_id string Thread ID +---@return boolean success +function M.resolve_thread(thread_id) + local storage_backend = get_storage() + local resolved_by = vim.fn.expand("$USER") + local success = storage_backend.update_thread_status(thread_id, "resolved", resolved_by) + + if success then + M.refresh_ui() + vim.notify("Thread resolved", vim.log.levels.INFO) + end + + return success +end + +--- Reopen a thread +---@param thread_id string Thread ID +---@return boolean success +function M.reopen_thread(thread_id) + local storage_backend = get_storage() + local success = storage_backend.update_thread_status(thread_id, "open", nil) + + if success then + M.refresh_ui() + vim.notify("Thread reopened", vim.log.levels.INFO) + end + + return success +end + +--- Get all thread statuses +---@return table +function M.get_all_threads() + local storage_backend = get_storage() + if storage_backend.get_all_threads then + return storage_backend.get_all_threads() + end + return {} +end + +--- Get storage backend (for internal use) +---@return table storage +function M.get_storage() + return get_storage() +end + --- Reset internal state (for testing purposes) ---@private function M._reset() diff --git a/lua/code-review/storage/file.lua b/lua/code-review/storage/file.lua index d82e59a..c9c59ed 100644 --- a/lua/code-review/storage/file.lua +++ b/lua/code-review/storage/file.lua @@ -6,6 +6,30 @@ local storage_dir = nil local comments_cache = nil local cache_timestamp = 0 +--- Parse timestamp from frontmatter format +---@param time_str string? Timestamp string +---@return number? timestamp +local function parse_timestamp_from_frontmatter(time_str) + if not time_str then + return nil + end + + -- Try to parse "2025-07-08 10:43:54" format + local year, month, day, hour, min, sec = time_str:match("(%d+)%-(%d+)%-(%d+) (%d+):(%d+):(%d+)") + if year then + return os.time({ + year = tonumber(year), + month = tonumber(month), + day = tonumber(day), + hour = tonumber(hour), + min = tonumber(min), + sec = tonumber(sec), + }) + end + + return nil +end + --- Get storage directory ---@return string local function get_storage_dir() @@ -35,55 +59,153 @@ end --- Parse comment from file content ---@param content string ---@param filename string ----@return table|nil +---@return table[] comments local function parse_comment_from_file(content, filename) -- Extract ID from filename - local id = filename:match("^(.+)%.md$") - if not id then - return nil + local base_id = filename:match("^(.+)%.md$") + if not base_id then + return {} end - -- Parse the markdown content - local comment_data = { - id = id, - file = "", - line_start = 0, - line_end = 0, - comment = "", - context_lines = {}, - timestamp = 0, - } - - -- Simple parser for our markdown format local lines = vim.split(content, "\n", { plain = true }) - local state = "header" + local state = "start" + local frontmatter = {} local context_lines = {} - local comment_lines = {} + local in_context_code = false + local comments = {} + + -- Variables for parsing multiple comments + local current_comment = nil + local current_comment_lines = {} + local in_comments_section = false for _, line in ipairs(lines) do - if state == "header" and line:match("^## (.+):(%d+)-(%d+)$") then - local file, start_line, end_line = line:match("^## (.+):(%d+)-(%d+)$") - comment_data.file = file - comment_data.line_start = tonumber(start_line) - comment_data.line_end = tonumber(end_line) - elseif state == "header" and line:match("^%*%*Time%*%*: (.+)$") then - -- Try to parse timestamp (this is simplified, might need better parsing) - comment_data.timestamp = os.time() - elseif line == "### Context" then - state = "context" - elseif line == "### Comment" then - state = "comment" - elseif state == "context" and not line:match("^```") and not line:match("^###") then - table.insert(context_lines, line) - elseif state == "comment" and line ~= "" then - table.insert(comment_lines, line) + if state == "start" and line == "---" then + state = "frontmatter" + elseif state == "frontmatter" and line == "---" then + state = "content" + elseif state == "frontmatter" then + -- Parse YAML line + local key, value = line:match("^([^:]+):%s*(.+)$") + if key and value then + frontmatter[key] = value + end + elseif state == "content" then + if line == "## Context" then + state = "context" + elseif line == "## Comment" then + -- Old single-comment format + state = "comment" + elseif line == "## Comments" then + -- New multi-comment format + state = "comments" + in_comments_section = true + end + elseif state == "context" then + if line == "## Comment" or line == "## Comments" then + state = line == "## Comment" and "comment" or "comments" + in_comments_section = line == "## Comments" + elseif line:match("^```") then + in_context_code = not in_context_code + elseif in_context_code then + table.insert(context_lines, line) + end + elseif state == "comment" then + -- Old format: single comment + table.insert(current_comment_lines, line) + elseif state == "comments" then + -- New format: multiple comments + if line:match("^### ") then + -- Save previous comment if exists + if current_comment and #current_comment_lines > 0 then + current_comment.comment = vim.trim(table.concat(current_comment_lines, "\n")) + table.insert(comments, current_comment) + current_comment_lines = {} + end + + -- Parse comment header: "### Author - Timestamp" + local header = line:sub(5) -- Remove "### " + local author, timestamp_str = header:match("^(.+) %- (.+)$") + local parsed_author = author or vim.fn.expand("$USER") + + -- Parse timestamp from string (format: "2025-07-08 10:43:54") + local parsed_timestamp + if timestamp_str then + local year, month, day, hour, min, sec = timestamp_str:match("(%d+)%-(%d+)%-(%d+) (%d+):(%d+):(%d+)") + if year then + parsed_timestamp = os.time({ + year = tonumber(year), + month = tonumber(month), + day = tonumber(day), + hour = tonumber(hour), + min = tonumber(min), + sec = tonumber(sec), + }) + else + parsed_timestamp = os.time() + end + else + parsed_timestamp = os.time() + end + + -- Start new comment + current_comment = { + id = base_id .. "_comment_" .. #comments, + file = frontmatter.file or "", + line_start = tonumber(frontmatter.line_start) or 0, + line_end = tonumber(frontmatter.line_end) or 0, + author = parsed_author, + timestamp = parsed_timestamp, + context_lines = context_lines, + thread_id = frontmatter.thread_id, + thread_status = frontmatter.thread_status or "open", + resolved_by = frontmatter.resolved_by ~= "null" and frontmatter.resolved_by or nil, + resolved_at = frontmatter.resolved_at ~= "null" and tonumber(frontmatter.resolved_at) or nil, + } + elseif line == "---" and in_comments_section then -- luacheck: ignore 542 + -- Comment separator, ignore + elseif line == "" and not current_comment then -- luacheck: ignore 542 + -- Empty line before first comment, ignore + else + -- Comment content + table.insert(current_comment_lines, line) + end + end + end + + -- Handle last comment or old format + if state == "comment" or (current_comment and #current_comment_lines > 0) then + if state == "comment" then + -- Old single-comment format + local comment_data = { + id = base_id, + file = frontmatter.file or "", + line_start = tonumber(frontmatter.line_start) or 0, + line_end = tonumber(frontmatter.line_end) or 0, + comment = table.concat(current_comment_lines, "\n"), + context_lines = context_lines, + timestamp = parse_timestamp_from_frontmatter(frontmatter.time) or os.time(), + author = frontmatter.author, + thread_id = frontmatter.thread_id, + parent_id = frontmatter.parent_id ~= "null" and frontmatter.parent_id or nil, + thread_status = frontmatter.thread_status or "open", + resolved_by = frontmatter.resolved_by ~= "null" and frontmatter.resolved_by or nil, + resolved_at = frontmatter.resolved_at ~= "null" and tonumber(frontmatter.resolved_at) or nil, + } + return { comment_data } + else + -- Save last comment in multi-comment format + current_comment.comment = vim.trim(table.concat(current_comment_lines, "\n")) + table.insert(comments, current_comment) end end - comment_data.context_lines = context_lines - comment_data.comment = table.concat(comment_lines, "\n") + -- For multi-comment format, ensure root comment has correct ID + if #comments > 0 and comments[1] and not comments[1].parent_id then + comments[1].id = base_id + end - return comment_data + return comments end --- Load all comments from storage directory @@ -107,12 +229,19 @@ local function load_comments() -- Read all .md files in the directory local files = vim.fn.glob(dir .. "/*.md", false, true) for _, filepath in ipairs(files) do - local content = vim.fn.readfile(filepath) - if #content > 0 then - local filename = vim.fn.fnamemodify(filepath, ":t") - local comment_data = parse_comment_from_file(table.concat(content, "\n"), filename) - if comment_data then - table.insert(comments, comment_data) + -- Use io.open to preserve trailing newlines + local file = io.open(filepath, "r") + if file then + local content = file:read("*a") + file:close() + + if content and #content > 0 then + local filename = vim.fn.fnamemodify(filepath, ":t") + local parsed_comments = parse_comment_from_file(content, filename) + -- parse_comment_from_file now returns an array of comments + for _, comment_data in ipairs(parsed_comments) do + table.insert(comments, comment_data) + end end end end @@ -137,6 +266,22 @@ function M.init() if vim.fn.isdirectory(dir) == 0 then vim.fn.mkdir(dir, "p") end + + -- Migrate comments from comments/ subdirectory back to root + local comments_dir = dir .. "/comments" + if vim.fn.isdirectory(comments_dir) == 1 then + local comment_files = vim.fn.glob(comments_dir .. "/*.md", false, true) + for _, filepath in ipairs(comment_files) do + local filename = vim.fn.fnamemodify(filepath, ":t") + local new_filepath = dir .. "/" .. filename + -- Move file to root directory + if vim.fn.filereadable(new_filepath) == 0 then + vim.fn.rename(filepath, new_filepath) + end + end + -- Remove empty comments directory + vim.fn.delete(comments_dir, "d") + end end --- Check if storage is active @@ -152,6 +297,51 @@ function M.add(comment_data) -- Add metadata comment_data.timestamp = comment_data.timestamp or os.time() + -- If this is a reply, update the root comment's file instead + if comment_data.parent_id and comment_data.thread_id then + -- Find the root comment of this thread + local comments = load_comments() + local root_comment = nil + + for _, comment in ipairs(comments) do + if comment.thread_id == comment_data.thread_id and not comment.parent_id then + root_comment = comment + break + end + end + + if root_comment then + -- Get all comments in this thread + local thread_comments = {} + for _, comment in ipairs(comments) do + if comment.thread_id == comment_data.thread_id then + table.insert(thread_comments, comment) + end + end + + -- Add the new reply + table.insert(thread_comments, comment_data) + + -- Sort by timestamp to maintain chronological order + table.sort(thread_comments, function(a, b) + return (a.timestamp or 0) < (b.timestamp or 0) + end) + + -- Update the root comment file with all thread comments + local filename = root_comment.id .. ".md" + local filepath = get_storage_dir() .. "/" .. filename + local formatted_text = M.format_thread_as_markdown(thread_comments) + + if utils.save_to_file(filepath, formatted_text) then + invalidate_cache() + return comment_data.id + else + error("Failed to save reply to file") + end + end + end + + -- For new comments (not replies), create a new file local dir = get_storage_dir() local filename = get_comment_filename(comment_data) comment_data.id = filename:match("^(.+)%.md$") @@ -159,7 +349,6 @@ function M.add(comment_data) local filepath = dir .. "/" .. filename -- Format the comment with full context - -- We need to format it ourselves to avoid circular dependency local formatted_text = M.format_comment_as_markdown(comment_data) if utils.save_to_file(filepath, formatted_text) then @@ -242,17 +431,50 @@ function M.format_comment_as_markdown(comment_data) local config = require("code-review.config") local date_format = config.get("output.date_format") - -- Header with file and line info - table.insert(lines, string.format("## %s:%d-%d", comment_data.file, comment_data.line_start, comment_data.line_end)) - table.insert(lines, "") + -- YAML frontmatter + table.insert(lines, "---") + table.insert(lines, "file: " .. comment_data.file) + table.insert(lines, "line_start: " .. comment_data.line_start) + table.insert(lines, "line_end: " .. comment_data.line_end) + table.insert(lines, "time: " .. os.date(date_format, comment_data.timestamp)) + + if comment_data.author then + table.insert(lines, "author: " .. comment_data.author) + end + + if comment_data.thread_id then + table.insert(lines, "thread_id: " .. comment_data.thread_id) + end + + if comment_data.parent_id then + table.insert(lines, "parent_id: " .. comment_data.parent_id) + else + table.insert(lines, "parent_id: null") + end - -- Timestamp - table.insert(lines, string.format("**Time**: %s", os.date(date_format, comment_data.timestamp))) + -- Thread status (only for root comments) + if comment_data.thread_id and not comment_data.parent_id then + table.insert(lines, "thread_status: " .. (comment_data.thread_status or "open")) + + if comment_data.resolved_by then + table.insert(lines, "resolved_by: " .. comment_data.resolved_by) + else + table.insert(lines, "resolved_by: null") + end + + if comment_data.resolved_at then + table.insert(lines, "resolved_at: " .. comment_data.resolved_at) + else + table.insert(lines, "resolved_at: null") + end + end + + table.insert(lines, "---") table.insert(lines, "") -- Code context if available if comment_data.context_lines and #comment_data.context_lines > 0 then - table.insert(lines, "### Context") + table.insert(lines, "## Context") table.insert(lines, "") table.insert(lines, "```" .. vim.fn.fnamemodify(comment_data.file, ":e")) for _, line in ipairs(comment_data.context_lines) do @@ -262,17 +484,195 @@ function M.format_comment_as_markdown(comment_data) table.insert(lines, "") end - -- Comment content - table.insert(lines, "### Comment") + -- Comments section (even for single comment, use consistent format) + table.insert(lines, "## Comments") + table.insert(lines, "") + table.insert( + lines, + "### " .. (comment_data.author or vim.fn.expand("$USER")) .. " - " .. os.date(date_format, comment_data.timestamp) + ) table.insert(lines, "") table.insert(lines, comment_data.comment) return table.concat(lines, "\n") end +--- Get thread information from comments +---@param thread_id string +---@return table|nil +function M.get_thread(thread_id) + local comments = load_comments() + + -- Find the root comment of this thread + for _, comment in ipairs(comments) do + if comment.thread_id == thread_id and not comment.parent_id then + return { + id = thread_id, + status = comment.thread_status or "open", + root_comment_id = comment.id, + resolved_by = comment.resolved_by, + resolved_at = comment.resolved_at, + } + end + end + + return nil +end + --- Reload comments from storage (invalidate cache) function M.reload() invalidate_cache() end +--- Update thread status by updating all comments in the thread +---@param thread_id string Thread ID +---@param status string New status +---@param resolved_by string|nil User who resolved +---@return boolean success +function M.update_thread_status(thread_id, status, resolved_by) + local comments = load_comments() + local updated = false + + -- Update all comments in this thread + for _, comment in ipairs(comments) do + if comment.thread_id == thread_id then + comment.thread_status = status + + -- Only update resolved info on root comment + if not comment.parent_id then + if status == "resolved" and resolved_by then + comment.resolved_by = resolved_by + comment.resolved_at = os.time() + elseif status == "open" then + comment.resolved_by = nil + comment.resolved_at = nil + end + end + + -- Save updated comment + local filename = comment.id .. ".md" + local filepath = get_storage_dir() .. "/" .. filename + local formatted_text = M.format_comment_as_markdown(comment) + + if utils.save_to_file(filepath, formatted_text) then + updated = true + end + end + end + + if updated then + invalidate_cache() + end + + return updated +end + +--- Get all threads by extracting from comments +---@return table +function M.get_all_threads() + local comments = load_comments() + local threads = {} + + -- Extract thread info from root comments + for _, comment in ipairs(comments) do + if comment.thread_id and not comment.parent_id then + threads[comment.thread_id] = { + id = comment.thread_id, + status = comment.thread_status or "open", + root_comment_id = comment.id, + resolved_by = comment.resolved_by, + resolved_at = comment.resolved_at, + } + end + end + + return threads +end + +--- Format a thread (multiple comments) as markdown +---@param thread_comments table[] Comments in the thread, sorted by timestamp +---@return string +function M.format_thread_as_markdown(thread_comments) + if #thread_comments == 0 then + return "" + end + + local lines = {} + local config = require("code-review.config") + local date_format = config.get("output.date_format") + + -- Find the root comment (should be the first one) + local root_comment = thread_comments[1] + + -- YAML frontmatter from root comment + table.insert(lines, "---") + table.insert(lines, "file: " .. root_comment.file) + table.insert(lines, "line_start: " .. root_comment.line_start) + table.insert(lines, "line_end: " .. root_comment.line_end) + table.insert(lines, "time: " .. os.date(date_format, root_comment.timestamp)) + + if root_comment.author then + table.insert(lines, "author: " .. root_comment.author) + end + + if root_comment.thread_id then + table.insert(lines, "thread_id: " .. root_comment.thread_id) + end + + table.insert(lines, "parent_id: null") + table.insert(lines, "thread_status: " .. (root_comment.thread_status or "open")) + + if root_comment.resolved_by then + table.insert(lines, "resolved_by: " .. root_comment.resolved_by) + else + table.insert(lines, "resolved_by: null") + end + + if root_comment.resolved_at then + table.insert(lines, "resolved_at: " .. root_comment.resolved_at) + else + table.insert(lines, "resolved_at: null") + end + + table.insert(lines, "---") + table.insert(lines, "") + + -- Code context (from root comment) + if root_comment.context_lines and #root_comment.context_lines > 0 then + table.insert(lines, "## Context") + table.insert(lines, "") + table.insert(lines, "```" .. vim.fn.fnamemodify(root_comment.file, ":e")) + for _, line in ipairs(root_comment.context_lines) do + table.insert(lines, line) + end + table.insert(lines, "```") + table.insert(lines, "") + end + + -- Comments section + table.insert(lines, "## Comments") + table.insert(lines, "") + + -- Add each comment in the thread + for i, comment in ipairs(thread_comments) do + if i > 1 then + table.insert(lines, "") + table.insert(lines, "---") + table.insert(lines, "") + end + + -- Comment metadata + table.insert( + lines, + "### " .. (comment.author or vim.fn.expand("$USER")) .. " - " .. os.date(date_format, comment.timestamp) + ) + table.insert(lines, "") + + -- Comment content + table.insert(lines, comment.comment) + end + + return table.concat(lines, "\n") +end + return M diff --git a/lua/code-review/storage/memory.lua b/lua/code-review/storage/memory.lua index 2557d66..0896775 100644 --- a/lua/code-review/storage/memory.lua +++ b/lua/code-review/storage/memory.lua @@ -29,7 +29,11 @@ function M.add(comment_data) end -- Add metadata - comment_data.id = vim.fn.localtime() .. "_" .. math.random(1000, 9999) + if not comment_data.id then + -- Use a more reliable ID generation to avoid collisions + local ms = vim.fn.reltimefloat(vim.fn.reltime()) * 1000 + comment_data.id = string.format("%d_%03d_%04d", vim.fn.localtime(), ms % 1000, math.random(1000, 9999)) + end comment_data.timestamp = comment_data.timestamp or os.time() table.insert(session.comments, comment_data) @@ -86,6 +90,74 @@ function M.get_at_location(file, line) return results end +--- Update thread status by updating comments +---@param thread_id string Thread ID +---@param status string New status +---@param resolved_by string|nil User who resolved +---@return boolean success +function M.update_thread_status(thread_id, status, resolved_by) + local updated = false + + for _, comment in ipairs(session.comments) do + if comment.thread_id == thread_id then + comment.thread_status = status + + -- Only update resolved info on root comment + if not comment.parent_id then + if status == "resolved" and resolved_by then + comment.resolved_by = resolved_by + comment.resolved_at = os.time() + elseif status == "open" then + comment.resolved_by = nil + comment.resolved_at = nil + end + end + + updated = true + end + end + + return updated +end + +--- Get thread by ID from comments +---@param thread_id string +---@return table|nil +function M.get_thread(thread_id) + for _, comment in ipairs(session.comments) do + if comment.thread_id == thread_id and not comment.parent_id then + return { + id = thread_id, + status = comment.thread_status or "open", + root_comment_id = comment.id, + resolved_by = comment.resolved_by, + resolved_at = comment.resolved_at, + } + end + end + return nil +end + +--- Get all threads from comments +---@return table +function M.get_all_threads() + local threads = {} + + for _, comment in ipairs(session.comments) do + if comment.thread_id and not comment.parent_id then + threads[comment.thread_id] = { + id = comment.thread_id, + status = comment.thread_status or "open", + root_comment_id = comment.id, + resolved_by = comment.resolved_by, + resolved_at = comment.resolved_at, + } + end + end + + return threads +end + --- Reset internal state (for testing purposes) ---@private function M._reset() diff --git a/lua/code-review/thread.lua b/lua/code-review/thread.lua new file mode 100644 index 0000000..7a99e9f --- /dev/null +++ b/lua/code-review/thread.lua @@ -0,0 +1,139 @@ +---@class CodeReviewThread +---@field id string Thread ID +---@field root_comment_id string ID of the root comment +---@field status string Thread status: 'open', 'resolved', 'outdated' +---@field resolved_by string|nil User who resolved the thread +---@field resolved_at number|nil Timestamp when resolved + +---@class CodeReviewComment +---@field id string Comment ID +---@field thread_id string Thread this comment belongs to +---@field parent_id string|nil Parent comment ID (nil for root comments) +---@field file string File path +---@field line_start number Start line +---@field line_end number End line +---@field comment string Comment text +---@field author string Comment author +---@field timestamp number Creation timestamp +---@field context_lines table Context lines from the file +---@field replies table List of reply comment IDs + +local M = {} + +--- Create a new thread +---@param root_comment table The root comment data +---@return table thread +function M.create_thread(root_comment) + local thread_id = root_comment.id .. "_thread" + + return { + id = thread_id, + root_comment_id = root_comment.id, + status = "open", + resolved_by = nil, + resolved_at = nil, + } +end + +--- Create a reply to a comment +---@param parent_comment table The parent comment +---@param reply_text string The reply text +---@param author string The author of the reply +---@return table reply +function M.create_reply(parent_comment, reply_text, author) + local reply_id = vim.fn.localtime() .. "_reply_" .. math.random(1000, 9999) + + return { + id = reply_id, + thread_id = parent_comment.thread_id, + parent_id = parent_comment.id, + file = parent_comment.file, + line_start = parent_comment.line_start, + line_end = parent_comment.line_end, + comment = reply_text, + author = author or vim.fn.expand("$USER"), + timestamp = os.time(), + context_lines = parent_comment.context_lines, -- Inherit context from parent + replies = {}, + } +end + +--- Build thread structure from flat comment list +---@param comments table[] List of comments +---@return table threads Thread structure with linear replies +function M.build_thread_tree(comments) + local threads = {} + local comment_map = {} + + -- First pass: create comment map + for _, comment in ipairs(comments) do + comment_map[comment.id] = vim.deepcopy(comment) + end + + -- Second pass: organize into threads + for _, comment in pairs(comment_map) do + if not comment.parent_id then + -- Root comment - create thread + local thread_id = comment.thread_id or (comment.id .. "_thread") + threads[thread_id] = { + id = thread_id, + root_comment = comment, + replies = {}, -- Linear list of replies + status = "open", + } + end + end + + -- Third pass: add replies to threads in chronological order + for _, comment in ipairs(comments) do + if comment.parent_id and comment.thread_id then + local thread = threads[comment.thread_id] + if thread then + table.insert(thread.replies, comment) + end + end + end + + return threads +end + +--- Flatten thread structure to comment list +---@param threads table Thread structure +---@return table[] comments Flat list of comments +function M.flatten_thread_tree(threads) + local comments = {} + + for _, thread in pairs(threads) do + if thread.root_comment then + -- Add root comment + table.insert(comments, thread.root_comment) + + -- Add all replies in order + if thread.replies then + for _, reply in ipairs(thread.replies) do + table.insert(comments, reply) + end + end + end + end + + return comments +end + +--- Get all comments in a thread +---@param thread_id string +---@param comments table[] All comments +---@return table[] thread_comments +function M.get_thread_comments(thread_id, comments) + local thread_comments = {} + + for _, comment in ipairs(comments) do + if comment.thread_id == thread_id then + table.insert(thread_comments, comment) + end + end + + return thread_comments +end + +return M diff --git a/lua/code-review/ui.lua b/lua/code-review/ui.lua index 56f0691..6593c5f 100644 --- a/lua/code-review/ui.lua +++ b/lua/code-review/ui.lua @@ -2,10 +2,113 @@ local M = {} local config = require("code-review.config") +--- Show generic input window +---@param opts table Options: title, on_submit, initial_text +function M.get_input(opts) + opts = opts or {} + local conf = config.get("ui.input_window") + + -- Create buffer + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_option(buf, "bufhidden", "wipe") + vim.api.nvim_buf_set_option(buf, "filetype", "markdown") + vim.api.nvim_buf_set_option(buf, "wrap", true) + vim.api.nvim_buf_set_option(buf, "linebreak", true) + vim.api.nvim_buf_set_option(buf, "breakindent", true) + + -- Set initial text if provided + if opts.initial_text then + vim.api.nvim_buf_set_lines(buf, 0, -1, false, vim.split(opts.initial_text, "\n")) + end + + -- Calculate window size and position + local width = conf.width + local height = conf.height or 10 + + -- Create window centered + local win = vim.api.nvim_open_win(buf, true, { + relative = "editor", + row = math.floor((vim.o.lines - height) / 2), + col = math.floor((vim.o.columns - width) / 2), + width = width, + height = height, + style = "minimal", + border = conf.border, + title = opts.title or "Input", + title_pos = "center", + }) + + -- Setup keymaps + local function submit() + -- Leave insert mode before closing + if vim.fn.mode() == "i" then + vim.cmd("stopinsert") + end + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + local text = table.concat(lines, "\n") + vim.api.nvim_win_close(win, true) + if opts.on_submit then + opts.on_submit(text) + end + end + + local function cancel() + -- Leave insert mode before closing + if vim.fn.mode() == "i" then + vim.cmd("stopinsert") + end + vim.api.nvim_win_close(win, true) + if opts.on_cancel then + opts.on_cancel() + end + end + + -- Buffer-local keymaps + vim.keymap.set("n", "", submit, { buffer = buf, nowait = true }) + vim.keymap.set("n", "", submit, { buffer = buf, nowait = true }) + vim.keymap.set("n", "q", cancel, { buffer = buf, nowait = true }) + vim.keymap.set("n", "", cancel, { buffer = buf, nowait = true }) + vim.keymap.set("i", "", submit, { buffer = buf, nowait = true }) + vim.keymap.set("i", "", cancel, { buffer = buf, nowait = true }) + + -- Start in insert mode + vim.cmd("startinsert") +end + +--- Select from a list of comments +---@param comments table[] List of comments +---@param callback function(comment?) Called with selected comment or nil +function M.select_comment(comments, callback) + if #comments == 0 then + callback(nil) + return + end + + local items = {} + for i, comment in ipairs(comments) do + local preview = comment.comment:sub(1, 50) + if #comment.comment > 50 then + preview = preview .. "..." + end + table.insert(items, string.format("%d. %s", i, preview)) + end + + vim.ui.select(items, { + prompt = "Select comment:", + }, function(choice, idx) + if choice and idx then + callback(comments[idx]) + else + callback(nil) + end + end) +end + --- Show comment input window ---@param callback function(string?) Called with the comment text or nil if cancelled ---@param context table? Optional context with line_start and line_end -function M.show_comment_input(callback, context) +---@param title string? Optional window title (defaults to config) +function M.show_comment_input(callback, context, title) local conf = config.get("ui.input_window") -- Create buffer @@ -64,7 +167,7 @@ function M.show_comment_input(callback, context) height = height, style = "minimal", border = conf.border, - title = conf.title, + title = title or conf.title, title_pos = conf.title_pos, }) @@ -283,21 +386,54 @@ end --- Show comment list in floating window ---@param comments table[] List of comments to show function M.show_comment_list(comments) - local comment_module = require("code-review.comment") local lines = {} - -- Format comments for display - for i, comment_data in ipairs(comments) do - if i > 1 then + -- Group comments by thread + local threads = {} + for _, comment in ipairs(comments) do + local thread_id = comment.thread_id or comment.id + if not threads[thread_id] then + threads[thread_id] = {} + end + table.insert(threads[thread_id], comment) + end + + -- Sort comments within each thread by timestamp + for _, thread_comments in pairs(threads) do + table.sort(thread_comments, function(a, b) + return (a.timestamp or 0) < (b.timestamp or 0) + end) + end + + -- Format each thread as in storage format (## Comments section only) + local first_thread = true + for _, thread_comments in pairs(threads) do + if not first_thread then table.insert(lines, "") table.insert(lines, "---") table.insert(lines, "") end + first_thread = false + + -- Add thread comments in the same format as the file (without ## Comments header) + for i, comment in ipairs(thread_comments) do + if i > 1 then + table.insert(lines, "") + table.insert(lines, "---") + table.insert(lines, "") + end + + -- Comment metadata + local cfg = require("code-review.config") + local date_format = cfg.get("output.date_format") + table.insert( + lines, + "### " .. (comment.author or vim.fn.expand("$USER")) .. " - " .. os.date(date_format, comment.timestamp) + ) + table.insert(lines, "") - -- Use common formatter (no ANSI for floating window) - local comment_lines = comment_module.format_as_markdown(comment_data, true, false) - for _, line in ipairs(comment_lines) do - table.insert(lines, line) + -- Comment content + table.insert(lines, comment.comment) end end diff --git a/tests/test_thread.lua b/tests/test_thread.lua new file mode 100644 index 0000000..ab3b9c2 --- /dev/null +++ b/tests/test_thread.lua @@ -0,0 +1,207 @@ +local state = require("code-review.state") +local thread = require("code-review.thread") + +-- Initialize plugin +require("code-review").setup({ + comment = { + storage = { backend = "memory" }, + }, +}) + +local T = MiniTest.new_set() + +-- Setup and teardown hooks +T["thread management"] = MiniTest.new_set({ + hooks = { + pre_case = function() + -- Reset and reinitialize for clean state + local memory = require("code-review.storage.memory") + + -- Use _reset for complete cleanup + state._reset() + memory._reset() + + -- Reinitialize + state.init() + + -- Clear any existing comments + state.clear() + end, + }, +}) + +T["thread management"]["creates thread for root comment"] = function() + -- Add a root comment + local comment_id = state.add_comment({ + file = "test.lua", + line_start = 1, + line_end = 5, + comment = "This needs refactoring", + }) + + -- Check that thread was created + local threads = state.get_all_threads() + local thread_count = vim.tbl_count(threads) + MiniTest.expect.equality(thread_count, 1) + + -- Verify thread properties + local thread_id = comment_id .. "_thread" + local thread_data = threads[thread_id] + MiniTest.expect.equality(thread_data ~= nil, true) + MiniTest.expect.equality(thread_data.status, "open") + MiniTest.expect.equality(thread_data.root_comment_id, comment_id) +end + +T["thread management"]["adds replies to thread"] = function() + -- Add root comment + local root_id = state.add_comment({ + file = "test.lua", + line_start = 10, + line_end = 10, + comment = "Original comment", + }) + + -- Add reply + local reply_id = state.add_reply(root_id, "I agree with this") + MiniTest.expect.equality(reply_id ~= nil, true) + + -- Check that reply has correct thread_id + local reply_comment = state.get_comment(reply_id) + MiniTest.expect.equality(reply_comment.thread_id, root_id .. "_thread") + MiniTest.expect.equality(reply_comment.parent_id, root_id) +end + +T["thread management"]["resolves and reopens threads"] = function() + -- Add comment to create thread + local comment_id = state.add_comment({ + file = "test.lua", + line_start = 1, + line_end = 1, + comment = "Fix this bug", + }) + + local thread_id = comment_id .. "_thread" + + -- Resolve thread + local success = state.resolve_thread(thread_id) + MiniTest.expect.equality(success, true) + + -- Check thread status + local threads = state.get_all_threads() + MiniTest.expect.equality(threads[thread_id].status, "resolved") + MiniTest.expect.equality(threads[thread_id].resolved_by ~= nil, true) + MiniTest.expect.equality(threads[thread_id].resolved_at ~= nil, true) + + -- Reopen thread + success = state.reopen_thread(thread_id) + MiniTest.expect.equality(success, true) + + -- Check thread status again + threads = state.get_all_threads() + MiniTest.expect.equality(threads[thread_id].status, "open") + MiniTest.expect.equality(threads[thread_id].resolved_by, nil) + MiniTest.expect.equality(threads[thread_id].resolved_at, nil) +end + +T["thread management"]["builds thread tree from comments"] = function() + -- Create a thread with multiple comments + local root_id = state.add_comment({ + file = "test.lua", + line_start = 1, + line_end = 1, + comment = "Root comment", + }) + + -- Add replies + state.add_reply(root_id, "First reply") + state.add_reply(root_id, "Second reply") + + -- Get all comments and build thread tree + local comments = state.get_comments() + local threads = thread.build_thread_tree(comments) + + -- Verify thread structure + local thread_id = root_id .. "_thread" + MiniTest.expect.equality(threads[thread_id] ~= nil, true) + MiniTest.expect.equality(threads[thread_id].root_comment.id, root_id) + MiniTest.expect.equality(#threads[thread_id].replies, 2) +end + +T["thread management"]["gets thread comments"] = function() + -- Create thread + local root_id = state.add_comment({ + file = "test.lua", + line_start = 5, + line_end = 5, + comment = "Thread root", + }) + + local thread_id = root_id .. "_thread" + + -- Add replies + state.add_reply(root_id, "Reply 1") + state.add_reply(root_id, "Reply 2") + + -- Get thread comments + local thread_comments = state.get_thread_comments(thread_id) + MiniTest.expect.equality(#thread_comments, 3) -- root + 2 replies + + -- Verify all have same thread_id + for _, comment in ipairs(thread_comments) do + MiniTest.expect.equality(comment.thread_id, thread_id) + end +end + +T["thread management"]["handles multiple threads"] = function() + -- Create multiple threads + local thread_ids = {} + for i = 1, 3 do + local id = state.add_comment({ + file = "test" .. i .. ".lua", + line_start = i, + line_end = i, + comment = "Thread " .. i, + }) + table.insert(thread_ids, id .. "_thread") + end + + -- Check all threads exist + local threads = state.get_all_threads() + MiniTest.expect.equality(vim.tbl_count(threads), 3) + + for _, thread_id in ipairs(thread_ids) do + MiniTest.expect.equality(threads[thread_id] ~= nil, true) + MiniTest.expect.equality(threads[thread_id].status, "open") + end +end + +T["thread management"]["preserves thread state across storage backends"] = function() + -- Test with file storage - reinitialize with file backend + state._reset() + require("code-review.storage.memory")._reset() + require("code-review").setup({ + comment = { + storage = { backend = "file" }, + }, + }) + state.init() + + local comment_id = state.add_comment({ + file = "test.lua", + line_start = 1, + line_end = 1, + comment = "Test thread persistence", + }) + + local thread_id = comment_id .. "_thread" + state.resolve_thread(thread_id) + + -- Simulate reloading + state.sync_from_storage() + + -- Check thread state is preserved + local threads = state.get_all_threads() + MiniTest.expect.equality(threads[thread_id].status, "resolved") +end + +return T