diff --git a/.claude/scripts/process-review-file.sh b/.claude/scripts/process-review-file.sh new file mode 100755 index 0000000..f19f7e9 --- /dev/null +++ b/.claude/scripts/process-review-file.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +# Process a single review file +# Usage: ./process-review-file.sh + +if [ $# -ne 1 ]; then + echo "Usage: $0 " + exit 1 +fi + +FILE="$1" + +if [ ! -f "$FILE" ]; then + echo "Error: File not found: $FILE" + exit 1 +fi + +# Extract the ID from filename +BASENAME=$(basename "$FILE") +# Remove prefix and suffix using parameter expansion +ID="${BASENAME#action-required_}" +ID="${ID%.md}" + +echo "Processing file: $FILE" +echo "ID: $ID" + +# The actual processing will be done by Claude Code +# This script just handles the file renaming after processing + +# Check if we should rename (this will be called after Claude adds the reply) +if [ -f "$FILE" ]; then + # Replace prefix using parameter expansion + NEW_FILE="${FILE/action-required_/waiting-review_}" + echo "Renaming to: $NEW_FILE" + mv "$FILE" "$NEW_FILE" + echo "File renamed successfully" +else + echo "File no longer exists (may have been already processed)" +fi diff --git a/.claude/scripts/review-loop-monitor.sh b/.claude/scripts/review-loop-monitor.sh new file mode 100755 index 0000000..969a17d --- /dev/null +++ b/.claude/scripts/review-loop-monitor.sh @@ -0,0 +1,41 @@ +#!/bin/bash + +# Review Loop Monitor Script +# This script continuously monitors for action-required files and reports their status + +REVIEW_DIR=".code-review" +CHECK_INTERVAL=5 +STATUS_INTERVAL=30 +last_status_time=$(date +%s) + +echo "Starting review loop monitor..." +echo "Checking for action-required files every ${CHECK_INTERVAL} seconds" +echo "Status updates every ${STATUS_INTERVAL} seconds" +echo "---" + +while true; do + current_time=$(date +%s) + + # Find action-required files + files=$(find "$REVIEW_DIR" -name "action-required_*.md" -type f 2>/dev/null) + # Use grep -c for counting non-empty lines + file_count=$(echo "$files" | grep -c -v '^$' || echo "0") + + if [ -n "$files" ] && [ "$files" != "" ]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Found $file_count action-required file(s):" + echo "$files" | while read -r file; do + [ -n "$file" ] && echo " - $file" + done + echo "ACTION_REQUIRED_FOUND" + exit 0 + fi + + # Periodic status update + time_diff=$((current_time - last_status_time)) + if [ $time_diff -ge $STATUS_INTERVAL ]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] No action-required files found. Continuing to monitor..." + last_status_time=$current_time + fi + + sleep $CHECK_INTERVAL +done diff --git a/README.md b/README.md index 95d2bff..d04f00c 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,10 @@ require('code-review').setup({ }, }, auto_copy_on_add = false, -- Automatically copy each new comment to clipboard when added + -- Author name used by Claude Code (for automatic status management) + -- Comments from this author trigger "waiting-review" status + -- Comments from other authors trigger "action-required" status + claude_code_author = 'Claude Code', }, -- Keymaps (set to false to disable all keymaps) keymaps = { @@ -312,10 +316,23 @@ The preview buffer is fully editable. You can: 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]`) +- **Reply to comments**: Use `rr` to reply to existing comments +- **Create new threads**: Use `rc` to start a new thread on the same line +- **Resolve threads**: Use `ro` to mark a thread as resolved +- Thread status is displayed in the comment list: + - `[!]` Action Required - awaiting response from code author (Claude Code) + - `[⏳]` Waiting Review - awaiting reviewer response + - `[✓]` Resolved - discussion complete + +#### Automatic Status Management + +When using file storage backend, thread status is automatically managed based on the latest comment author: + +- Comments from `claude_code_author` (configurable) → `waiting-review` +- Comments from other authors → `action-required` +- Manual resolution → `resolved` + +This enables efficient workflow between AI assistants and human reviewers. ### Comment List Picker @@ -387,7 +404,6 @@ This function needs error handling for nil input. Consider using table.filter for better readability. ```` - ## 💾 Storage Backends code-review.nvim supports two storage backends: @@ -439,7 +455,6 @@ require('code-review').setup({ 3. Comments persist across Neovim sessions 4. External tools can monitor the directory for new files - ## 🔨 Development ### Setup diff --git a/lua/code-review/comment.lua b/lua/code-review/comment.lua index 4f4eef0..deb1452 100644 --- a/lua/code-review/comment.lua +++ b/lua/code-review/comment.lua @@ -127,7 +127,32 @@ end add_signs = function(bufnr, comments) local config = require("code-review.config").get("ui.signs") - -- Define sign if not already defined + -- Remove existing signs first + vim.fn.sign_unplace("CodeReviewSigns", { buffer = bufnr }) + + -- Define signs for each status (using same text but different colors) + vim.fn.sign_define("CodeReviewWaitingReview", { + text = config.text, + texthl = "CodeReviewWaitingReview", + linehl = config.linehl, + numhl = config.numhl, + }) + + vim.fn.sign_define("CodeReviewActionRequired", { + text = config.text, + texthl = "CodeReviewActionRequired", + linehl = config.linehl, + numhl = config.numhl, + }) + + vim.fn.sign_define("CodeReviewResolved", { + text = config.text, + texthl = "CodeReviewResolved", + linehl = config.linehl, + numhl = config.numhl, + }) + + -- Default sign for unknown status vim.fn.sign_define("CodeReviewComment", { text = config.text, texthl = config.texthl, @@ -135,11 +160,44 @@ add_signs = function(bufnr, comments) numhl = config.numhl, }) - -- Place signs + -- Group comments by line to determine thread status + local status_by_line = {} for _, comment in ipairs(comments) do for line = comment.line_start, comment.line_end do - vim.fn.sign_place(0, "CodeReviewSigns", "CodeReviewComment", bufnr, { lnum = line, priority = 100 }) + -- Determine status based on thread_status or thread info + local status = comment.thread_status or "open" + + -- If resolved thread, mark as resolved + if comment.thread_id then + local sign_state = require("code-review.state") + local thread_data = sign_state.get_all_threads()[comment.thread_id] + if thread_data and thread_data.status == "resolved" then + status = "resolved" + end + end + -- Priority: resolved < action-required < waiting-review + if not status_by_line[line] then + status_by_line[line] = status + elseif status == "waiting-review" then + status_by_line[line] = status + elseif status == "action-required" and status_by_line[line] ~= "waiting-review" then + status_by_line[line] = status + end + end + end + + -- Place signs based on status + for line, status in pairs(status_by_line) do + local sign_name = "CodeReviewComment" + if status == "waiting-review" then + sign_name = "CodeReviewWaitingReview" + elseif status == "action-required" then + sign_name = "CodeReviewActionRequired" + elseif status == "resolved" then + sign_name = "CodeReviewResolved" end + + vim.fn.sign_place(0, "CodeReviewSigns", sign_name, bufnr, { lnum = line, priority = 100 }) end end @@ -168,42 +226,71 @@ add_virtual_text = function(bufnr, comments) -- Add virtual text for line, line_threads in pairs(threads_by_line) do - local text = config.prefix local thread_count = vim.tbl_count(line_threads) + local text = "" + local highlight = config.hl + local show_virt_text = true if thread_count > 1 then -- Multiple threads on same line - text = text .. string.format("(%d threads)", thread_count) + text = config.prefix .. string.format("(%d threads)", thread_count) else -- 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] + local thread_id, thread_comments = next(line_threads) + + -- Determine thread status + local status = "open" + if thread_comments[1].thread_status then + status = thread_comments[1].thread_status + end + + -- Check if thread is resolved + local comment_state = require("code-review.state") + local thread_data = comment_state.get_all_threads()[thread_id] + if thread_data and thread_data.status == "resolved" then + status = "resolved" + show_virt_text = false -- Don't show virtual text for resolved 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) .. "..." + if show_virt_text then + -- 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 + + -- Set prefix based on status + local prefix = config.prefix + if status == "waiting-review" then + prefix = "󰇮 " -- Mail icon for waiting review (Nerd Font) + highlight = "CodeReviewWaitingReview" + elseif status == "action-required" then + prefix = "○ " + highlight = "CodeReviewActionRequired" + 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) .. "..." + end + text = prefix .. first_line end - text = text .. first_line end -- Ensure buffer is loaded and line is valid - if vim.api.nvim_buf_is_loaded(bufnr) then + if show_virt_text and text ~= "" and vim.api.nvim_buf_is_loaded(bufnr) then local line_count = vim.api.nvim_buf_line_count(bufnr) if line <= line_count then pcall(vim.api.nvim_buf_set_extmark, bufnr, ns_virtual_text, line - 1, 0, { - virt_text = { { text, config.hl } }, + virt_text = { { text, highlight } }, virt_text_pos = "eol", }) end diff --git a/lua/code-review/config.lua b/lua/code-review/config.lua index 0d62dee..fd86048 100644 --- a/lua/code-review/config.lua +++ b/lua/code-review/config.lua @@ -6,7 +6,7 @@ local defaults = { ui = { -- Floating window settings for comment input input_window = { - width = 60, + width = 80, height = 1, max_height = 20, -- Maximum height when content requires scrolling border = "rounded", @@ -16,10 +16,10 @@ local defaults = { -- Preview window settings preview = { split = "vertical", -- 'vertical' or 'horizontal' or 'float' - vertical_width = 80, + vertical_width = 100, horizontal_height = 20, float = { - width = 0.8, + width = 0.85, height = 0.8, border = "rounded", title = " Review Preview ", @@ -67,6 +67,10 @@ local defaults = { }, -- Automatically copy each new comment to clipboard when added auto_copy_on_add = false, + -- Author name used by Claude Code (for automatic status management) + -- Comments from this author trigger "waiting-review" status + -- Comments from other authors trigger "action-required" status + claude_code_author = "Claude Code", }, -- Keymaps (set to false to disable all keymaps) keymaps = { diff --git a/lua/code-review/init.lua b/lua/code-review/init.lua index f2846f4..c359761 100644 --- a/lua/code-review/init.lua +++ b/lua/code-review/init.lua @@ -13,6 +13,11 @@ function M.setup(opts) config.setup(opts or {}) state.init() + -- Define highlight groups for different statuses + vim.api.nvim_set_hl(0, "CodeReviewWaitingReview", { fg = "#50fa7b", default = true }) -- Green (informative) + vim.api.nvim_set_hl(0, "CodeReviewActionRequired", { fg = "#6c7086", default = true }) -- Light gray (pending) + vim.api.nvim_set_hl(0, "CodeReviewResolved", { fg = "#44475a", default = true }) -- Dark gray (resolved) + -- Create commands vim.api.nvim_create_user_command("CodeReviewClear", function() M.clear() @@ -225,13 +230,79 @@ function M.show_comment_at_cursor() return end - -- Show comments in floating window - ui.show_comment_list(line_comments) + -- Group comments by thread + local threads = {} + for _, line_comment in ipairs(line_comments) do + local thread_id = line_comment.thread_id + if thread_id then + if not threads[thread_id] then + threads[thread_id] = {} + end + table.insert(threads[thread_id], line_comment) + end + end + + local thread_count = vim.tbl_count(threads) + + if thread_count == 0 then + -- No threads, show all comments + ui.show_comment_list(line_comments) + elseif thread_count == 1 then + -- Single thread, show all its comments + local thread_id = next(threads) + local thread_comments = state.get_thread_comments(thread_id) + ui.show_comment_list(thread_comments) + else + -- Multiple threads, let user choose + local thread_list = {} + local all_threads = state.get_all_threads() + + for thread_id, _ in pairs(threads) do + local thread_data = all_threads[thread_id] + local thread_comments = state.get_thread_comments(thread_id) + local preview = "" + if #thread_comments > 0 then + preview = thread_comments[1].comment:sub(1, 50) + if #thread_comments[1].comment > 50 then + preview = preview .. "..." + end + end + + table.insert(thread_list, { + id = thread_id, + display = string.format( + "[%s] %s (%d comments)", + thread_data and thread_data.status or "open", + preview, + #thread_comments + ), + thread_id = thread_id, + }) + end + + -- Sort by thread ID for consistent ordering + table.sort(thread_list, function(a, b) + return a.id < b.id + end) + + -- Show selection UI + vim.ui.select(thread_list, { + prompt = "Select thread to view:", + format_item = function(item) + return item.display + end, + }, function(choice) + if choice then + local thread_comments = state.get_thread_comments(choice.thread_id) + ui.show_comment_list(thread_comments) + end + end) + end end --- List all comments function M.list_comments() - require("code-review.list").list_comments() + require("code-review.list").list_threads() end --- Reply to comment at cursor position @@ -366,7 +437,46 @@ function M.resolve_thread_at_cursor() state.resolve_thread(thread_id) else -- Multiple threads, let user choose - vim.notify("Multiple threads at this location", vim.log.levels.WARN) + local thread_list = {} + local all_threads = state.get_all_threads() + + for thread_id, _ in pairs(threads) do + local thread_data = all_threads[thread_id] + if thread_data then + -- Get thread comments for preview + local thread_comments = state.get_thread_comments(thread_id) + local preview = "" + if #thread_comments > 0 then + preview = thread_comments[1].comment:sub(1, 50) + if #thread_comments[1].comment > 50 then + preview = preview .. "..." + end + end + + table.insert(thread_list, { + id = thread_id, + display = string.format("[%s] %s (%d comments)", thread_data.status or "open", preview, #thread_comments), + thread_data = thread_data, + }) + end + end + + -- Sort by thread ID for consistent ordering + table.sort(thread_list, function(a, b) + return a.id < b.id + end) + + -- Show selection UI + vim.ui.select(thread_list, { + prompt = "Select thread to resolve:", + format_item = function(item) + return item.display + end, + }, function(choice) + if choice then + state.resolve_thread(choice.id) + end + end) end end diff --git a/lua/code-review/list.lua b/lua/code-review/list.lua index 748094a..5fe58f0 100644 --- a/lua/code-review/list.lua +++ b/lua/code-review/list.lua @@ -36,9 +36,8 @@ function M.list_with_quickfix() 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 {} + -- Get thread statuses from storage + local all_threads = state.get_all_threads() -- Sort threads by file and line local sorted_threads = {} @@ -57,16 +56,26 @@ function M.list_with_quickfix() -- Convert to quickfix items with thread grouping local qf_items = {} for _, thread_data in ipairs(sorted_threads) do - local thread_status = thread_statuses[thread_data.id] + local thread_info = all_threads[thread_data.id] local status_indicator = "" - if thread_status then - if thread_status.status == "resolved" then + + if thread_info then + if thread_info.status == "resolved" then status_indicator = "[✓] " - elseif thread_status.status == "outdated" then - status_indicator = "[~] " + elseif thread_data.root_comment.thread_status == "waiting-review" then + status_indicator = "[󰇮] " -- Mail icon, match virtual text + elseif thread_data.root_comment.thread_status == "action-required" then + status_indicator = "[○] " -- Match virtual text else status_indicator = "[•] " end + else + -- Check thread_status from comment + if thread_data.root_comment.thread_status == "waiting-review" then + status_indicator = "[󰇮] " -- Mail icon, match virtual text + elseif thread_data.root_comment.thread_status == "action-required" then + status_indicator = "[○] " -- Match virtual text + end end -- Add root comment with thread indicator @@ -395,4 +404,472 @@ function M.list_comments() M.list_with_quickfix() end +--- List all threads using quickfix +function M.list_threads_with_quickfix() + local comments = state.get_comments() + + if #comments == 0 then + vim.notify("No threads to display", vim.log.levels.INFO) + return + end + + -- Build thread tree + local thread = require("code-review.thread") + local threads = thread.build_thread_tree(comments) + + -- Get thread statuses + local all_threads = state.get_all_threads() + + -- Convert threads to quickfix items + local qf_items = {} + for thread_id, thread_data in pairs(threads) do + local root_comment = thread_data.root_comment + local thread_status = all_threads[thread_id] and all_threads[thread_id].status or "open" + + -- Get status icon (match virtual text) + local status_icon = "" + if root_comment.thread_status == "action-required" then + status_icon = "[○] " -- Match virtual text + elseif root_comment.thread_status == "waiting-review" then + status_icon = "[󰇮] " -- Mail icon, match virtual text + elseif thread_status == "resolved" then + status_icon = "[✓] " + end + + -- Create preview text with thread info + local text = string.format( + "%s%s (%d comments)", + status_icon, + root_comment.comment:match("^[^\n]*") or root_comment.comment, + #thread_data.replies + 1 + ) + + if #text > 80 then + text = text:sub(1, 77) .. "..." + end + + table.insert(qf_items, { + filename = root_comment.file, + lnum = root_comment.line_start, + col = 1, + text = text, + user_data = root_comment, + }) + end + + -- Sort by file and line + table.sort(qf_items, function(a, b) + if a.filename ~= b.filename then + return a.filename < b.filename + end + return a.lnum < b.lnum + end) + + -- Set quickfix list + vim.fn.setqflist({}, "r", { + title = "Code Review Threads", + items = qf_items, + }) + + -- Open quickfix window + vim.cmd("copen") +end + +--- List all threads using fzf-lua +function M.list_threads_with_fzf_lua() + local ok, fzf = pcall(require, "fzf-lua") + if not ok then + return false + end + + local comments = state.get_comments() + if #comments == 0 then + vim.notify("No threads to display", vim.log.levels.INFO) + return true + end + + -- Build thread tree + local thread = require("code-review.thread") + local threads = thread.build_thread_tree(comments) + local all_threads = state.get_all_threads() + + -- Convert to sorted list + local thread_list = {} + for thread_id, thread_data in pairs(threads) do + table.insert(thread_list, { + id = thread_id, + data = thread_data, + status = all_threads[thread_id] and all_threads[thread_id].status or "open", + }) + end + + -- Sort by file and line + table.sort(thread_list, function(a, b) + local a_root = a.data.root_comment + local b_root = b.data.root_comment + if a_root.file ~= b_root.file then + return a_root.file < b_root.file + end + return a_root.line_start < b_root.line_start + end) + + -- Create entries + local entries = {} + for _, thread_info in ipairs(thread_list) do + local root_comment = thread_info.data.root_comment + + -- Get status icon (match virtual text) + local status_icon = "" + if root_comment.thread_status == "action-required" then + status_icon = "○ " -- Match virtual text + elseif root_comment.thread_status == "waiting-review" then + status_icon = "󰇮 " -- Mail icon, match virtual text + elseif thread_info.status == "resolved" then + status_icon = "✓ " + end + + local line_info = root_comment.line_start == root_comment.line_end and tostring(root_comment.line_start) + or string.format("%d-%d", root_comment.line_start, root_comment.line_end) + + local preview_text = root_comment.comment:match("^[^\n]*") or root_comment.comment + if #preview_text > 50 then + preview_text = preview_text:sub(1, 47) .. "..." + end + + local entry = string.format( + "%s:%s: %s%s (%d comments)", + root_comment.file, + line_info, + status_icon, + preview_text, + #thread_info.data.replies + 1 + ) + + table.insert(entries, { + display = entry, + thread_id = thread_info.id, + root_comment = root_comment, + }) + end + + -- Create preview buffers for all threads + local comment_module = require("code-review.comment") + local preview_buffers = {} + local temp_buffers = {} + + for _, thread_info in ipairs(thread_list) do + -- Get all comments in thread + local thread_comments = state.get_thread_comments(thread_info.id) + + -- Create a scratch buffer + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_option(bufnr, "buftype", "nofile") + vim.api.nvim_buf_set_option(bufnr, "bufhidden", "hide") + vim.api.nvim_buf_set_option(bufnr, "swapfile", false) + vim.api.nvim_buf_set_option(bufnr, "filetype", "markdown") + + -- Format thread as markdown + local lines = {} + table.insert(lines, "# Thread Overview") + table.insert(lines, "") + table.insert(lines, string.format("**Status**: %s", thread_info.status)) + table.insert(lines, string.format("**File**: %s", thread_info.data.root_comment.file)) + table.insert(lines, string.format("**Line**: %d", thread_info.data.root_comment.line_start)) + table.insert(lines, string.format("**Comments**: %d", #thread_comments)) + table.insert(lines, "") + table.insert(lines, "---") + table.insert(lines, "") + + -- Add all comments + for j, comment in ipairs(thread_comments) do + table.insert(lines, string.format("## Comment %d", j)) + table.insert(lines, "") + local comment_lines = comment_module.format_as_markdown(comment, true, false) + for _, line in ipairs(comment_lines) do + table.insert(lines, line) + end + if j < #thread_comments then + table.insert(lines, "") + table.insert(lines, "---") + table.insert(lines, "") + end + end + + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + preview_buffers[thread_info.id] = bufnr + table.insert(temp_buffers, bufnr) + end + + -- Create custom previewer + local builtin = require("fzf-lua.previewer.builtin") + local ThreadPreviewer = builtin.buffer_or_file:extend() + + function ThreadPreviewer:new(o, opts, fzf_win) + ThreadPreviewer.super.new(self, o, opts, fzf_win) + self.title = "Thread Details" + self.syntax = true + self.syntax_limit_l = 0 + -- Store references to data needed in populate_preview_buf + self.entries = entries + self.preview_buffers = preview_buffers + return self + end + + function ThreadPreviewer:populate_preview_buf(entry_str) + if not self.win or not self.win:validate_preview() then + return + end + + -- Find matching entry by display string + local matched_buffer = nil + local matched_thread = nil + for _, entry in ipairs(self.entries) do + if entry.display == entry_str then + matched_buffer = self.preview_buffers[entry.thread_id] + matched_thread = entry + break + end + end + + if not matched_buffer then + return + end + + -- Get content from the prepared buffer + local lines = vim.api.nvim_buf_get_lines(matched_buffer, 0, -1, false) + + -- Get or create temp buffer for preview + local tmpbuf = self:get_tmp_buffer() + + -- Set the content + vim.api.nvim_buf_set_lines(tmpbuf, 0, -1, false, lines) + + -- Set filetype for syntax highlighting + vim.api.nvim_buf_set_option(tmpbuf, "filetype", "markdown") + + -- Set preview buffer + self:set_preview_buf(tmpbuf) + + -- Store current thread info for title update + self.current_thread = matched_thread + + -- Update title and other post processing + self:preview_buf_post({ path = "thread.md", line = 1, col = 1 }) + end + + function ThreadPreviewer:parse_entry(entry_str) + -- We handle everything in populate_preview_buf + return { path = "dummy", line = 1, col = 1 } + end + + function ThreadPreviewer:update_title(entry) + -- Override the title update to show thread info instead of temp file name + if self.current_thread then + local root = self.current_thread.root_comment + local line_info = root.line_start == root.line_end and tostring(root.line_start) + or string.format("%d-%d", root.line_start, root.line_end) + local title = string.format("%s:%s", root.file, line_info) + -- Don't apply title_fnamemodify - we want full path + self.win:update_preview_title(" " .. title .. " ") + else + -- Fallback to parent implementation + ThreadPreviewer.super.update_title(self, entry) + end + end + + -- Setup cleanup function + local function cleanup_temp_buffers() + for _, bufnr in ipairs(temp_buffers) do + if vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_buf_delete(bufnr, { force = true }) + end + end + end + + -- Create display strings + local display_strings = {} + for _, entry in ipairs(entries) do + table.insert(display_strings, entry.display) + end + + fzf.fzf_exec(display_strings, { + prompt = "Code Review Threads> ", + previewer = { + _ctor = function() + return ThreadPreviewer + end, + }, + preview_window = "right:50%:wrap", + fn_post = function() + vim.defer_fn(cleanup_temp_buffers, 100) + end, + actions = { + ["default"] = function(selected) + if not selected or #selected == 0 then + return + end + + -- Find matching entry + local line = selected[1] + for _, entry in ipairs(entries) do + if entry.display == line then + local comment = entry.root_comment + vim.cmd("edit " .. comment.file) + vim.api.nvim_win_set_cursor(0, { comment.line_start, 0 }) + break + end + end + end, + }, + }) + + return true +end + +--- List all threads using telescope +function M.list_threads_with_telescope() + local ok = pcall(require, "telescope") + if not ok then + return false + end + + local pickers = require("telescope.pickers") + local finders = require("telescope.finders") + local conf = require("telescope.config").values + local actions = require("telescope.actions") + local action_state = require("telescope.actions.state") + local entry_display = require("telescope.pickers.entry_display") + + local comments = state.get_comments() + if #comments == 0 then + vim.notify("No threads to display", vim.log.levels.INFO) + return true + end + + -- Build thread tree + local thread = require("code-review.thread") + local threads = thread.build_thread_tree(comments) + local all_threads = state.get_all_threads() + + -- Convert to sorted list + local thread_list = {} + for thread_id, thread_data in pairs(threads) do + table.insert(thread_list, { + id = thread_id, + data = thread_data, + status = all_threads[thread_id] and all_threads[thread_id].status or "open", + }) + end + + -- Sort by file and line + table.sort(thread_list, function(a, b) + local a_root = a.data.root_comment + local b_root = b.data.root_comment + if a_root.file ~= b_root.file then + return a_root.file < b_root.file + end + return a_root.line_start < b_root.line_start + end) + + -- Create displayer + local displayer = entry_display.create({ + separator = " │ ", + items = { + { width = 3 }, -- icon + { width = 30 }, -- file + { width = 6 }, -- line + { width = 50 }, -- preview + { remaining = true }, -- comment count + }, + }) + + local function make_display(entry) + local thread_info = entry.value + local root_comment = thread_info.data.root_comment + + -- Get status icon and highlight (match virtual text) + local status_icon = "" + local icon_hl = "Comment" + if root_comment.thread_status == "action-required" then + status_icon = "○" -- Match virtual text + icon_hl = "CodeReviewActionRequired" + elseif root_comment.thread_status == "waiting-review" then + status_icon = "󰇮" -- Mail icon, match virtual text + icon_hl = "CodeReviewWaitingReview" + elseif thread_info.status == "resolved" then + status_icon = "✓" + icon_hl = "CodeReviewResolved" + end + + local filename = vim.fn.fnamemodify(root_comment.file, ":~:.") + local line_info = root_comment.line_start == root_comment.line_end and tostring(root_comment.line_start) + or string.format("%d-%d", root_comment.line_start, root_comment.line_end) + + local preview_text = root_comment.comment:match("^[^\n]*") or root_comment.comment + if #preview_text > 50 then + preview_text = preview_text:sub(1, 47) .. "..." + end + + local comment_count = string.format("(%d comments)", #thread_info.data.replies + 1) + + return displayer({ + { status_icon, icon_hl }, + { filename, "TelescopeResultsIdentifier" }, + { line_info, "TelescopeResultsNumber" }, + { preview_text, "TelescopeResultsComment" }, + { comment_count, "Comment" }, + }) + end + + pickers + .new({}, { + prompt_title = "Code Review Threads", + finder = finders.new_table({ + results = thread_list, + entry_maker = function(thread_info) + local root_comment = thread_info.data.root_comment + return { + value = thread_info, + display = make_display, + ordinal = string.format("%s:%d %s", root_comment.file, root_comment.line_start, root_comment.comment), + filename = root_comment.file, + lnum = root_comment.line_start, + col = 1, + } + end, + }), + sorter = conf.generic_sorter({}), + attach_mappings = function(prompt_bufnr, map) + actions.select_default:replace(function() + actions.close(prompt_bufnr) + local selection = action_state.get_selected_entry() + if selection then + vim.cmd("edit " .. selection.filename) + vim.api.nvim_win_set_cursor(0, { selection.lnum, 0 }) + end + end) + return true + end, + }) + :find() + + return true +end + +--- List all threads +function M.list_threads() + -- Try Telescope first + if M.list_threads_with_telescope() then + return + end + + -- Try fzf-lua + if M.list_threads_with_fzf_lua() then + return + end + + -- Fallback to quickfix + M.list_threads_with_quickfix() +end + return M diff --git a/lua/code-review/state.lua b/lua/code-review/state.lua index 8079b55..3627d0e 100644 --- a/lua/code-review/state.lua +++ b/lua/code-review/state.lua @@ -76,7 +76,7 @@ function M.add_comment(comment_data) -- 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 + -- For root comments, set thread_id if not comment_data.parent_id then local thread_id = id .. "_thread" @@ -84,7 +84,7 @@ function M.add_comment(comment_data) local comment = storage_backend.get(id) if comment then comment.thread_id = thread_id - comment.thread_status = "open" + -- Status is now managed by filename, not in data -- Re-save with thread info storage_backend.delete(id) diff --git a/lua/code-review/storage/file.lua b/lua/code-review/storage/file.lua index c9c59ed..8b1a735 100644 --- a/lua/code-review/storage/file.lua +++ b/lua/code-review/storage/file.lua @@ -6,28 +6,53 @@ 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), - }) +--- Parse status from filename +---@param filename string +---@return string status, string id +local function parse_filename(filename) + -- Pattern: status_timestamp_thread.md + local status, id = filename:match("^([^_]+)_(.+)%.md$") + if status and id then + return status, id end - return nil + -- Legacy format: timestamp_thread.md + local legacy_id = filename:match("^(.+)%.md$") + if legacy_id then + return "action-required", legacy_id + end + + return nil, nil +end + +--- Generate filename with status +---@param id string +---@param status string +---@return string +local function make_filename(id, status) + return status .. "_" .. id .. ".md" +end + +--- Determine thread status based on latest author +---@param thread_comments table[] +---@return string status +local function determine_thread_status(thread_comments) + if #thread_comments == 0 then + return "action-required" + end + + -- Get the latest comment + local latest_comment = thread_comments[#thread_comments] + local config = require("code-review.config") + local claude_code_author = config.get("comment.claude_code_author") + + -- If latest author is Claude Code, status is "waiting-review" + -- Otherwise, status is "action-required" + if latest_comment.author == claude_code_author then + return "waiting-review" + else + return "action-required" + end end --- Get storage directory @@ -45,15 +70,23 @@ end --- Generate filename for a comment ---@param comment_data table +---@param status string? Optional status override ---@return string -local function get_comment_filename(comment_data) +local function get_comment_filename(comment_data, status) + local id if comment_data.id then - -- Use existing ID for filename - return comment_data.id .. ".md" + -- Extract ID from existing filename if needed + local _, parsed_id = parse_filename(comment_data.id .. ".md") + id = parsed_id or comment_data.id else - -- Generate new filename - return utils.generate_auto_save_filename() + -- Generate new ID + local filename = utils.generate_auto_save_filename() + id = filename:match("^(.+)%.md$") end + + -- Default status for new comments is "action-required" + status = status or "action-required" + return make_filename(id, status) end --- Parse comment from file content @@ -61,10 +94,14 @@ end ---@param filename string ---@return table[] comments local function parse_comment_from_file(content, filename) - -- Extract ID from filename - local base_id = filename:match("^(.+)%.md$") + -- Parse status and ID from filename + local status, base_id = parse_filename(filename) if not base_id then - return {} + -- Fallback for legacy format + base_id = filename:match("^(.+)%.md$") + if not base_id then + return {} + end end local lines = vim.split(content, "\n", { plain = true }) @@ -93,18 +130,15 @@ local function parse_comment_from_file(content, filename) 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 + -- 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" + if line == "## Comments" or line == "## Comment Thread" then + state = "comments" + in_comments_section = true elseif line:match("^```") then in_context_code = not in_context_code elseif in_context_code then @@ -158,9 +192,7 @@ local function parse_comment_from_file(content, filename) 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, + thread_status = status, -- Add status from filename } elseif line == "---" and in_comments_section then -- luacheck: ignore 542 -- Comment separator, ignore @@ -173,31 +205,11 @@ local function parse_comment_from_file(content, filename) 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 + -- Handle last comment + if current_comment and #current_comment_lines > 0 then + -- Save last comment in multi-comment format + current_comment.comment = vim.trim(table.concat(current_comment_lines, "\n")) + table.insert(comments, current_comment) end -- For multi-comment format, ensure root comment has correct ID @@ -307,6 +319,10 @@ function M.add(comment_data) if comment.thread_id == comment_data.thread_id and not comment.parent_id then root_comment = comment break + elseif not comment.parent_id and comment.id .. "_thread" == comment_data.thread_id then + -- Fallback: check if comment ID + "_thread" matches thread_id + root_comment = comment + break end end @@ -327,12 +343,27 @@ function M.add(comment_data) 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 + -- Determine new status based on latest author + local new_status = determine_thread_status(thread_comments) + + -- Get current filename from existing file + local old_files = vim.fn.glob(get_storage_dir() .. "/*_" .. root_comment.id .. ".md", false, true) + local old_filepath = old_files[1] + + -- Generate new filename with updated status + local new_filename = make_filename(root_comment.id, new_status) + local new_filepath = get_storage_dir() .. "/" .. new_filename + + -- Format content local formatted_text = M.format_thread_as_markdown(thread_comments) - if utils.save_to_file(filepath, formatted_text) then + -- If filename needs to change, delete old file first + if old_filepath and old_filepath ~= new_filepath then + vim.fn.delete(old_filepath) + end + + -- Save to new/same file + if utils.save_to_file(new_filepath, formatted_text) then invalidate_cache() return comment_data.id else @@ -344,7 +375,9 @@ function M.add(comment_data) -- 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$") + -- Extract ID without status prefix + local _, id = parse_filename(filename) + comment_data.id = id or filename:match("^(.+)%.md$") local filepath = dir .. "/" .. filename @@ -383,10 +416,19 @@ end ---@return boolean success function M.delete(id) local dir = get_storage_dir() - local filepath = dir .. "/" .. id .. ".md" - if vim.fn.filereadable(filepath) == 1 then - vim.fn.delete(filepath) + -- Find file with any status prefix + local files = vim.fn.glob(dir .. "/*_" .. id .. ".md", false, true) + if #files > 0 then + vim.fn.delete(files[1]) + invalidate_cache() + return true + end + + -- Fallback for legacy format + local legacy_filepath = dir .. "/" .. id .. ".md" + if vim.fn.filereadable(legacy_filepath) == 1 then + vim.fn.delete(legacy_filepath) invalidate_cache() return true end @@ -446,28 +488,8 @@ function M.format_comment_as_markdown(comment_data) 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 - - -- 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 + -- Removed: parent_id, thread_status, resolved_by, resolved_at + -- Status is now derived from filename table.insert(lines, "---") table.insert(lines, "") @@ -505,13 +527,23 @@ function M.get_thread(thread_id) -- 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 + if comment.thread_id == thread_id and (not comment.parent_id or comment.id == thread_id:match("^(.+)_thread$")) then + -- Find the file to get status + local files = vim.fn.glob(get_storage_dir() .. "/*_" .. comment.id .. ".md", false, true) + local status = "action-required" + + if files[1] then + local filename = vim.fn.fnamemodify(files[1], ":t") + local parsed_status = parse_filename(filename) + if parsed_status then + status = parsed_status + end + end + return { id = thread_id, - status = comment.thread_status or "open", + status = status, root_comment_id = comment.id, - resolved_by = comment.resolved_by, - resolved_at = comment.resolved_at, } end end @@ -524,47 +556,61 @@ function M.reload() invalidate_cache() end ---- Update thread status by updating all comments in the thread +--- Update thread status by renaming the file ---@param thread_id string Thread ID ----@param status string New status ----@param resolved_by string|nil User who resolved +---@param status string New status ("resolved", "open", etc.) +---@param resolved_by string|nil User who resolved (unused now) ---@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 + -- Find root comment of this thread + local root_comment = nil + local thread_comments = {} + 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 + table.insert(thread_comments, comment) + if not root_comment or not comment.parent_id then + root_comment = comment end + 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 not root_comment then + return false + end - if utils.save_to_file(filepath, formatted_text) then - updated = true - end - end + -- Map generic status to filename status + local filename_status + if status == "resolved" then + filename_status = "resolved" + elseif status == "open" then + -- Determine based on latest author + filename_status = determine_thread_status(thread_comments) + else + filename_status = status + end + + -- Find current file + local old_files = vim.fn.glob(get_storage_dir() .. "/*_" .. root_comment.id .. ".md", false, true) + local old_filepath = old_files[1] + + if not old_filepath then + return false end - if updated then + -- Generate new filename + local new_filename = make_filename(root_comment.id, filename_status) + local new_filepath = get_storage_dir() .. "/" .. new_filename + + -- Rename file if needed + if old_filepath ~= new_filepath then + vim.fn.rename(old_filepath, new_filepath) invalidate_cache() end - return updated + return true end --- Get all threads by extracting from comments @@ -572,16 +618,26 @@ end function M.get_all_threads() local comments = load_comments() local threads = {} + local thread_files = {} + + -- First, map files to thread IDs + local files = vim.fn.glob(get_storage_dir() .. "/*.md", false, true) + for _, filepath in ipairs(files) do + local filename = vim.fn.fnamemodify(filepath, ":t") + local status, id = parse_filename(filename) + if status and id then + thread_files[id] = status + end + end -- Extract thread info from root comments for _, comment in ipairs(comments) do - if comment.thread_id and not comment.parent_id then + if comment.thread_id and (not comment.parent_id or comment.id == comment.thread_id:match("^(.+)_thread$")) then + local status = thread_files[comment.id] or "action-required" threads[comment.thread_id] = { id = comment.thread_id, - status = comment.thread_status or "open", + status = status, root_comment_id = comment.id, - resolved_by = comment.resolved_by, - resolved_at = comment.resolved_at, } end end @@ -619,20 +675,8 @@ function M.format_thread_as_markdown(thread_comments) 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 + -- Removed: parent_id, thread_status, resolved_by, resolved_at + -- Status is now derived from filename table.insert(lines, "---") table.insert(lines, "") @@ -675,4 +719,9 @@ function M.format_thread_as_markdown(thread_comments) return table.concat(lines, "\n") end +-- Export internal functions for testing +M.parse_filename = parse_filename +M.make_filename = make_filename +M.determine_thread_status = determine_thread_status + return M diff --git a/lua/code-review/thread.lua b/lua/code-review/thread.lua index 7a99e9f..a56996c 100644 --- a/lua/code-review/thread.lua +++ b/lua/code-review/thread.lua @@ -46,7 +46,7 @@ function M.create_reply(parent_comment, reply_text, author) return { id = reply_id, thread_id = parent_comment.thread_id, - parent_id = parent_comment.id, + parent_id = parent_comment.id, -- Keep for internal use, but not saved to frontmatter file = parent_comment.file, line_start = parent_comment.line_start, line_end = parent_comment.line_end, diff --git a/lua/code-review/ui.lua b/lua/code-review/ui.lua index 6593c5f..5868053 100644 --- a/lua/code-review/ui.lua +++ b/lua/code-review/ui.lua @@ -432,13 +432,15 @@ function M.show_comment_list(comments) ) table.insert(lines, "") - -- Comment content - table.insert(lines, comment.comment) + -- Comment content (split by lines to avoid newline issues) + for line in comment.comment:gmatch("[^\n]+") do + table.insert(lines, line) + end end end -- Calculate window size - local width = 60 + local width = 80 local height = math.min(#lines + 2, 20) -- Create buffer @@ -466,7 +468,7 @@ function M.show_comment_list(comments) end -- Create window - vim.api.nvim_open_win(buf, true, { + local win = vim.api.nvim_open_win(buf, true, { relative = "cursor", row = row_offset, col = 0, @@ -478,6 +480,10 @@ function M.show_comment_list(comments) title_pos = "center", }) + -- Enable word wrap in the floating window + vim.api.nvim_win_set_option(win, "wrap", true) + vim.api.nvim_win_set_option(win, "linebreak", true) + -- Setup keymaps vim.api.nvim_buf_set_keymap(buf, "n", "q", "close", { noremap = true, diff --git a/tests/test_filename_status.lua b/tests/test_filename_status.lua new file mode 100644 index 0000000..79e99f7 --- /dev/null +++ b/tests/test_filename_status.lua @@ -0,0 +1,324 @@ +local state = require("code-review.state") +local file_storage = require("code-review.storage.file") + +-- Initialize plugin with file storage for realistic testing +require("code-review").setup({ + comment = { + storage = { backend = "file" }, + claude_code_author = "Claude Code", + }, +}) + +local T = MiniTest.new_set() + +-- Setup and teardown hooks +T["filename status management"] = MiniTest.new_set({ + hooks = { + pre_case = function() + -- Reset state + state._reset() + state.init() + state.clear() + + -- Clean up any test files + vim.fn.system("rm -rf .code-review/test_*") + end, + post_case = function() + -- Clean up test files + vim.fn.system("rm -rf .code-review/test_*") + end, + }, +}) + +T["filename status management"]["parse_filename extracts status and id"] = function() + -- Test various filename formats + local test_cases = { + { + filename = "action-required_1234567890.md", + expected_status = "action-required", + expected_id = "1234567890", + }, + { + filename = "waiting-review_9876543210.md", + expected_status = "waiting-review", + expected_id = "9876543210", + }, + { + filename = "resolved_1111111111.md", + expected_status = "resolved", + expected_id = "1111111111", + }, + { + filename = "action-required_1234567890_thread.md", + expected_status = "action-required", + expected_id = "1234567890_thread", + }, + -- Test invalid formats + -- Test legacy format (backward compatibility) + { + filename = "1234567890.md", + expected_status = "action-required", + expected_id = "1234567890", + }, + -- Test invalid formats + { + filename = "invalid.txt", + expected_status = nil, + expected_id = nil, + }, + } + + for _, test in ipairs(test_cases) do + local status, id = file_storage.parse_filename(test.filename) + MiniTest.expect.equality(status, test.expected_status) + MiniTest.expect.equality(id, test.expected_id) + end +end + +T["filename status management"]["make_filename creates correct filename"] = function() + -- Test filename generation + local test_cases = { + { + id = "1234567890", + status = "action-required", + expected = "action-required_1234567890.md", + }, + { + id = "9876543210", + status = "waiting-review", + expected = "waiting-review_9876543210.md", + }, + { + id = "1111111111", + status = "resolved", + expected = "resolved_1111111111.md", + }, + { + id = "1234567890_thread", + status = "action-required", + expected = "action-required_1234567890_thread.md", + }, + } + + for _, test in ipairs(test_cases) do + local filename = file_storage.make_filename(test.id, test.status) + MiniTest.expect.equality(filename, test.expected) + end +end + +T["filename status management"]["determine_thread_status returns correct status"] = function() + -- Test status determination based on author + local test_cases = { + { + comments = { { author = "Claude Code", comment = "Test", time = os.time() } }, + expected = "waiting-review", + description = "Claude Code as latest author should result in waiting-review", + }, + { + comments = { { author = "User", comment = "Test", time = os.time() } }, + expected = "action-required", + description = "Non-Claude Code author should result in action-required", + }, + { + comments = { + { author = "User", comment = "Initial", time = os.time() - 100 }, + { author = "Claude Code", comment = "Reply", time = os.time() }, + }, + expected = "waiting-review", + description = "Claude Code as latest reply should result in waiting-review", + }, + { + comments = { + { author = "Claude Code", comment = "Initial", time = os.time() - 100 }, + { author = "User", comment = "Reply", time = os.time() }, + }, + expected = "action-required", + description = "User as latest reply should result in action-required", + }, + { + comments = {}, + expected = "action-required", + description = "Empty comments should result in action-required", + }, + } + + for _, test in ipairs(test_cases) do + local status = file_storage.determine_thread_status(test.comments) + MiniTest.expect.equality(status, test.expected) + end +end + +T["filename status management"]["file rename on reply"] = function() + -- Create a root comment + local root_id = state.add_comment({ + file = "test.lua", + line_start = 1, + line_end = 1, + comment = "Initial comment", + author = "User", + }) + + -- Wait a bit to ensure file is written + vim.wait(100) + + -- Check initial filename + local files = vim.fn.glob(".code-review/waiting-review_" .. root_id .. ".md", false, true) + MiniTest.expect.equality(#files, 1) + + -- Add a reply from Claude Code + state.add_reply(root_id, "I'll fix this", "Claude Code") + + -- Wait for file operations + vim.wait(100) + + -- Check that file was renamed to action-required + local old_files = vim.fn.glob(".code-review/waiting-review_" .. root_id .. ".md", false, true) + MiniTest.expect.equality(#old_files, 0) + + local new_files = vim.fn.glob(".code-review/action-required_" .. root_id .. ".md", false, true) + MiniTest.expect.equality(#new_files, 1) +end + +T["filename status management"]["thread file operations"] = function() + -- Create a thread by adding a comment + local comment_id = state.add_comment({ + file = "test_thread.lua", + line_start = 10, + line_end = 10, + comment = "Thread test comment", + author = "User", + }) + + local thread_id = comment_id .. "_thread" + + -- Wait for file creation + vim.wait(100) + + -- Check thread file exists with correct status + local thread_files = vim.fn.glob(".code-review/waiting-review_" .. thread_id .. ".md", false, true) + MiniTest.expect.equality(#thread_files, 1) + + -- Add reply from Claude Code to trigger status change + state.add_reply(comment_id, "Working on it", "Claude Code") + + -- Wait for file operations + vim.wait(100) + + -- Check that thread file was renamed + local old_thread_files = vim.fn.glob(".code-review/waiting-review_" .. thread_id .. ".md", false, true) + MiniTest.expect.equality(#old_thread_files, 0) + + local new_thread_files = vim.fn.glob(".code-review/action-required_" .. thread_id .. ".md", false, true) + MiniTest.expect.equality(#new_thread_files, 1) +end + +T["filename status management"]["wildcard search for file updates"] = function() + -- Create a comment + local comment_id = state.add_comment({ + file = "wildcard_test.lua", + line_start = 5, + line_end = 5, + comment = "Test wildcard search", + author = "User", + }) + + -- Wait for file creation + vim.wait(100) + + -- Update comment (should find file regardless of status prefix) + local success = state.update_comment(comment_id, { + comment = "Updated comment text", + }) + MiniTest.expect.equality(success, true) + + -- Wait for update + vim.wait(100) + + -- Read the file to verify update + local files = vim.fn.glob(".code-review/*_" .. comment_id .. ".md", false, true) + MiniTest.expect.equality(#files, 1) + + local content = vim.fn.readfile(files[1]) + local found_updated_text = false + for _, line in ipairs(content) do + if line:match("Updated comment text") then + found_updated_text = true + break + end + end + MiniTest.expect.equality(found_updated_text, true) +end + +T["filename status management"]["status preserved in list view"] = function() + -- Create comments with different statuses + local comment1_id = state.add_comment({ + file = "list_test.lua", + line_start = 1, + line_end = 1, + comment = "Comment from user", + author = "User", + }) + + local comment2_id = state.add_comment({ + file = "list_test.lua", + line_start = 5, + line_end = 5, + comment = "Comment from Claude Code", + author = "Claude Code", + }) + + -- Wait for files to be created + vim.wait(100) + + -- Get all comments + local comments = state.get_comments() + + -- Find our test comments + local comment1, comment2 + for _, c in ipairs(comments) do + if c.id == comment1_id then + comment1 = c + elseif c.id == comment2_id then + comment2 = c + end + end + + -- Check that thread_status is set based on filename + MiniTest.expect.equality(comment1.thread_status, "waiting-review") + MiniTest.expect.equality(comment2.thread_status, "action-required") +end + +T["filename status management"]["resolve thread updates filename"] = function() + -- Create a comment + local comment_id = state.add_comment({ + file = "resolve_test.lua", + line_start = 1, + line_end = 1, + comment = "To be resolved", + author = "User", + }) + + local thread_id = comment_id .. "_thread" + + -- Wait for file creation + vim.wait(100) + + -- Resolve the thread + state.resolve_thread(thread_id) + + -- Wait for file operations + vim.wait(100) + + -- Check that thread file was renamed to resolved + local waiting_files = vim.fn.glob(".code-review/waiting-review_" .. thread_id .. ".md", false, true) + MiniTest.expect.equality(#waiting_files, 0) + + local resolved_files = vim.fn.glob(".code-review/resolved_" .. thread_id .. ".md", false, true) + MiniTest.expect.equality(#resolved_files, 1) + + -- Also check comment file + local comment_resolved_files = vim.fn.glob(".code-review/resolved_" .. comment_id .. ".md", false, true) + MiniTest.expect.equality(#comment_resolved_files, 1) +end + +return T