From cc3143c35c79c5f8a57b8abb53845db73ccb3c9a Mon Sep 17 00:00:00 2001 From: Akihiro Okuno Date: Tue, 8 Jul 2025 13:09:57 +0900 Subject: [PATCH 01/12] feat: implement filename-based thread status management - Add automatic status tracking via filename prefixes: - action-required_: awaiting code author response - waiting-review_: awaiting reviewer response - resolved_: thread resolved - Simplify frontmatter by removing status fields - Add claude_code_author config for AI assistant detection - Update file operations to handle status changes via rename - Fix reply file creation to update existing thread file This enables efficient workflow tracking between AI assistants and human reviewers without parsing file contents. --- README.md | 25 ++- lua/code-review/config.lua | 4 + lua/code-review/list.lua | 18 +- lua/code-review/state.lua | 4 +- lua/code-review/storage/file.lua | 292 ++++++++++++++++++++----------- lua/code-review/thread.lua | 2 +- 6 files changed, 228 insertions(+), 117 deletions(-) diff --git a/README.md b/README.md index 95d2bff..de3e538 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 diff --git a/lua/code-review/config.lua b/lua/code-review/config.lua index 0d62dee..96032d5 100644 --- a/lua/code-review/config.lua +++ b/lua/code-review/config.lua @@ -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/list.lua b/lua/code-review/list.lua index 748094a..c1b11e5 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,13 +56,16 @@ 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_info.status == "waiting-review" then + status_indicator = "[⏳] " + elseif thread_info.status == "action-required" then + status_indicator = "[!] " else status_indicator = "[•] " end 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..8f29f38 100644 --- a/lua/code-review/storage/file.lua +++ b/lua/code-review/storage/file.lua @@ -30,6 +30,55 @@ local function parse_timestamp_from_frontmatter(time_str) return nil end +--- 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 + + -- 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 status string +---@param id string +---@return string +local function make_filename(status, id) + 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 ---@return string local function get_storage_dir() @@ -45,15 +94,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() - end + -- 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(status, id) end --- Parse comment from file content @@ -61,10 +118,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 }) @@ -158,9 +219,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, + -- Status is now derived from filename } elseif line == "---" and in_comments_section then -- luacheck: ignore 542 -- Comment separator, ignore @@ -187,10 +246,7 @@ local function parse_comment_from_file(content, filename) 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, + -- Removed: parent_id, thread_status, resolved_by, resolved_at } return { comment_data } else @@ -307,6 +363,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 +387,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(new_status, root_comment.id) + 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 +419,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 +460,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 @@ -445,29 +531,9 @@ function M.format_comment_as_markdown(comment_data) 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 - - -- 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 +571,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 +600,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 - 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 + table.insert(thread_comments, comment) + if not root_comment or not comment.parent_id then + root_comment = comment end end end - - if updated then + + if not root_comment then + return false + 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 + + -- Generate new filename + local new_filename = make_filename(filename_status, root_comment.id) + 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 +662,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 @@ -618,21 +718,9 @@ function M.format_thread_as_markdown(thread_comments) 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 + + -- Removed: parent_id, thread_status, resolved_by, resolved_at + -- Status is now derived from filename table.insert(lines, "---") table.insert(lines, "") 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, From 3f50f207ac8efda2f0b0596b0350f5d64076ab3d Mon Sep 17 00:00:00 2001 From: Akihiro Okuno Date: Tue, 8 Jul 2025 15:08:26 +0900 Subject: [PATCH 02/12] test: add comprehensive tests for filename-based status management - Add test_filename_status.lua with full coverage - Test parse_filename and make_filename functions - Test determine_thread_status logic - Test automatic file renaming on replies - Test thread file operations - Test wildcard search functionality - Test status preservation in list view - Test resolve thread filename updates - Export internal functions from storage/file.lua for testing --- lua/code-review/storage/file.lua | 15 +- tests/test_filename_status.lua | 341 +++++++++++++++++++++++++++++++ 2 files changed, 351 insertions(+), 5 deletions(-) create mode 100644 tests/test_filename_status.lua diff --git a/lua/code-review/storage/file.lua b/lua/code-review/storage/file.lua index 8f29f38..335abe2 100644 --- a/lua/code-review/storage/file.lua +++ b/lua/code-review/storage/file.lua @@ -50,10 +50,10 @@ local function parse_filename(filename) end --- Generate filename with status ----@param status string ---@param id string +---@param status string ---@return string -local function make_filename(status, id) +local function make_filename(id, status) return status .. "_" .. id .. ".md" end @@ -110,7 +110,7 @@ local function get_comment_filename(comment_data, status) -- Default status for new comments is "action-required" status = status or "action-required" - return make_filename(status, id) + return make_filename(id, status) end --- Parse comment from file content @@ -395,7 +395,7 @@ function M.add(comment_data) local old_filepath = old_files[1] -- Generate new filename with updated status - local new_filename = make_filename(new_status, root_comment.id) + local new_filename = make_filename(root_comment.id, new_status) local new_filepath = get_storage_dir() .. "/" .. new_filename -- Format content @@ -645,7 +645,7 @@ function M.update_thread_status(thread_id, status, resolved_by) end -- Generate new filename - local new_filename = make_filename(filename_status, root_comment.id) + local new_filename = make_filename(root_comment.id, filename_status) local new_filepath = get_storage_dir() .. "/" .. new_filename -- Rename file if needed @@ -763,4 +763,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/tests/test_filename_status.lua b/tests/test_filename_status.lua new file mode 100644 index 0000000..c813b55 --- /dev/null +++ b/tests/test_filename_status.lua @@ -0,0 +1,341 @@ +local state = require("code-review.state") +local file_storage = require("code-review.storage.file") +local config = require("code-review.config") + +-- 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() + +-- Helper function to create a mock comment +local function create_mock_comment(opts) + opts = opts or {} + return { + id = opts.id or "1234567890", + file = opts.file or "test.lua", + line_start = opts.line_start or 1, + line_end = opts.line_end or 1, + comment = opts.comment or "Test comment", + author = opts.author or "User", + time = opts.time or os.time(), + thread_id = opts.thread_id, + parent_id = opts.parent_id, + } +end + +-- 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 \ No newline at end of file From 2fbf4141b18c2ecd26855c629f23b84aaf2f9833 Mon Sep 17 00:00:00 2001 From: Akihiro Okuno Date: Tue, 8 Jul 2025 15:21:32 +0900 Subject: [PATCH 03/12] fix(ui): handle multiline comments in comment list display - Fix E5108 error when displaying comments with newlines - Split comment content by lines before passing to nvim_buf_set_lines - Ensures each line is added separately to avoid newline issues --- lua/code-review/ui.lua | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lua/code-review/ui.lua b/lua/code-review/ui.lua index 6593c5f..4c2549d 100644 --- a/lua/code-review/ui.lua +++ b/lua/code-review/ui.lua @@ -432,8 +432,10 @@ 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 From 82d00c2f6cfd587debeb4d21469fde1179d0b319 Mon Sep 17 00:00:00 2001 From: Akihiro Okuno Date: Tue, 8 Jul 2025 15:44:34 +0900 Subject: [PATCH 04/12] fix: add thread selection UI when multiple threads exist at cursor - Show selection dialog when resolving threads at cursor position - Display thread status, preview, and comment count for each thread - Sort threads by ID for consistent ordering - Previously only showed warning without allowing resolution --- .claude/scripts/process-review-file.sh | 39 ++++++++++++++++++++++++ .claude/scripts/review-loop-monitor.sh | 41 +++++++++++++++++++++++++ README.md | 2 -- lua/code-review/init.lua | 42 +++++++++++++++++++++++++- 4 files changed, 121 insertions(+), 3 deletions(-) create mode 100755 .claude/scripts/process-review-file.sh create mode 100755 .claude/scripts/review-loop-monitor.sh 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 de3e538..d04f00c 100644 --- a/README.md +++ b/README.md @@ -404,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: @@ -456,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/init.lua b/lua/code-review/init.lua index f2846f4..1afae4e 100644 --- a/lua/code-review/init.lua +++ b/lua/code-review/init.lua @@ -366,7 +366,47 @@ 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 From e4353254085183231991c9fae1cf55563e22feb3 Mon Sep 17 00:00:00 2001 From: Akihiro Okuno Date: Tue, 8 Jul 2025 15:50:22 +0900 Subject: [PATCH 05/12] feat: change rl to list threads instead of individual comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add list_threads() function to show thread-based view - Display thread status icons ([\!], [⏳], [✓]) - Show root comment preview and total comment count - Sort threads by file and line number - Currently supports quickfix list (telescope/fzf-lua support TODO) --- lua/code-review/init.lua | 2 +- lua/code-review/list.lua | 76 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/lua/code-review/init.lua b/lua/code-review/init.lua index 1afae4e..a10a847 100644 --- a/lua/code-review/init.lua +++ b/lua/code-review/init.lua @@ -231,7 +231,7 @@ 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 diff --git a/lua/code-review/list.lua b/lua/code-review/list.lua index c1b11e5..49dfab2 100644 --- a/lua/code-review/list.lua +++ b/lua/code-review/list.lua @@ -397,4 +397,80 @@ 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 + local status_icon = "" + if root_comment.thread_status == "action-required" then + status_icon = "[!] " + elseif root_comment.thread_status == "waiting-review" then + status_icon = "[⏳] " + 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 +function M.list_threads() + -- For now, only support quickfix for thread listing + -- TODO: Add telescope and fzf-lua support for threads + M.list_threads_with_quickfix() +end + return M From 541994a273ca8df89dd95d32b750dc3f223d9cfb Mon Sep 17 00:00:00 2001 From: Akihiro Okuno Date: Tue, 8 Jul 2025 15:53:10 +0900 Subject: [PATCH 06/12] feat: add thread selection for rs when multiple threads exist - Show thread selection UI when multiple threads at cursor - Single thread: show all comments in that thread - Multiple threads: let user choose which thread to view - Display thread status, preview, and comment count in selection - No threads: show all comments as before --- lua/code-review/init.lua | 66 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/lua/code-review/init.lua b/lua/code-review/init.lua index a10a847..b1080cf 100644 --- a/lua/code-review/init.lua +++ b/lua/code-review/init.lua @@ -225,8 +225,70 @@ 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 _, comment in ipairs(line_comments) do + local thread_id = comment.thread_id + if thread_id then + if not threads[thread_id] then + threads[thread_id] = {} + end + table.insert(threads[thread_id], 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, comments 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 From 45646c538b4006ef4829b06d04e1d434ca976ab9 Mon Sep 17 00:00:00 2001 From: Akihiro Okuno Date: Tue, 8 Jul 2025 15:54:10 +0900 Subject: [PATCH 07/12] feat(ui): enable word wrap in comment floating window - Add wrap=true to show long lines without horizontal scrolling - Add linebreak=true to wrap at word boundaries - Improves readability of long comments in rs --- lua/code-review/ui.lua | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lua/code-review/ui.lua b/lua/code-review/ui.lua index 4c2549d..637f895 100644 --- a/lua/code-review/ui.lua +++ b/lua/code-review/ui.lua @@ -468,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, @@ -479,6 +479,10 @@ function M.show_comment_list(comments) title = " Review 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", { From 8f8de85d4cc2afe5d5a763346d75603f6daba81e Mon Sep 17 00:00:00 2001 From: Akihiro Okuno Date: Tue, 8 Jul 2025 15:58:13 +0900 Subject: [PATCH 08/12] feat(ui): increase default width for popup windows - Comment input window: width 60 -> 80 - Preview window vertical split: width 80 -> 100 - Comment display window: width 60 -> 80 - Keep heights unchanged as they auto-expand based on content --- lua/code-review/config.lua | 6 +++--- lua/code-review/ui.lua | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lua/code-review/config.lua b/lua/code-review/config.lua index 96032d5..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 ", diff --git a/lua/code-review/ui.lua b/lua/code-review/ui.lua index 637f895..f3956c8 100644 --- a/lua/code-review/ui.lua +++ b/lua/code-review/ui.lua @@ -440,7 +440,7 @@ function M.show_comment_list(comments) end -- Calculate window size - local width = 60 + local width = 80 local height = math.min(#lines + 2, 20) -- Create buffer From 2bc371745532f53ebf66046c1ea8803285ad76f8 Mon Sep 17 00:00:00 2001 From: Akihiro Okuno Date: Tue, 8 Jul 2025 16:35:25 +0900 Subject: [PATCH 09/12] feat: add status-based colors and icons for comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add color differentiation for comment statuses: - waiting-review: green (#50fa7b) - needs user attention - action-required: light gray (#6c7086) - pending Claude Code - resolved: dark gray (#44475a) - completed - Keep gutter sign as vertical line (┃) with color variations - Add status-specific virtual text icons: - waiting-review: mail icon (󰇮) - action-required: circle (○) - resolved: hidden (gutter only) - Fix thread_status not being set from filename - Remove debug notifications --- lua/code-review/comment.lua | 135 +++++++++++++++++++++++++------ lua/code-review/init.lua | 5 ++ lua/code-review/storage/file.lua | 40 +++------ 3 files changed, 126 insertions(+), 54 deletions(-) diff --git a/lua/code-review/comment.lua b/lua/code-review/comment.lua index 4f4eef0..6535698 100644 --- a/lua/code-review/comment.lua +++ b/lua/code-review/comment.lua @@ -126,8 +126,33 @@ end ---@param comments table[] add_signs = function(bufnr, comments) local config = require("code-review.config").get("ui.signs") + + -- Remove existing signs first + vim.fn.sign_unplace("CodeReviewSigns", { buffer = bufnr }) - -- Define sign if not already defined + -- 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,12 +160,45 @@ 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 state = require("code-review.state") + local thread_data = 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 --- Add virtual text to buffer @@ -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 state = require("code-review.state") + local thread_data = 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/init.lua b/lua/code-review/init.lua index b1080cf..5a8e93d 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() diff --git a/lua/code-review/storage/file.lua b/lua/code-review/storage/file.lua index 335abe2..59afc38 100644 --- a/lua/code-review/storage/file.lua +++ b/lua/code-review/storage/file.lua @@ -154,18 +154,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 @@ -219,7 +216,7 @@ local function parse_comment_from_file(content, filename) timestamp = parsed_timestamp, context_lines = context_lines, thread_id = frontmatter.thread_id, - -- Status is now derived from filename + thread_status = status, -- Add status from filename } elseif line == "---" and in_comments_section then -- luacheck: ignore 542 -- Comment separator, ignore @@ -232,28 +229,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, - -- Removed: parent_id, thread_status, resolved_by, resolved_at - } - 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 From 3fcd3dd51675de25a643220b2f7fd4353dee70d4 Mon Sep 17 00:00:00 2001 From: Akihiro Okuno Date: Wed, 9 Jul 2025 19:47:36 +0900 Subject: [PATCH 10/12] feat(list): add fzf-lua and telescope support for thread listing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement list_threads_with_fzf_lua() with full preview support - Implement list_threads_with_telescope() with status-aware display - Unify status icons across all list views (quickfix, fzf-lua, telescope) - waiting-review: 󰇮 (mail icon) - action-required: ○ - resolved: ✓ - Apply status-specific colors in telescope view - Fix fzf-lua preview by properly storing entries and preview_buffers --- lua/code-review/comment.lua | 24 +- lua/code-review/init.lua | 45 ++-- lua/code-review/list.lua | 431 +++++++++++++++++++++++++++++-- lua/code-review/storage/file.lua | 52 ++-- lua/code-review/ui.lua | 2 +- tests/test_filename_status.lua | 14 +- 6 files changed, 485 insertions(+), 83 deletions(-) diff --git a/lua/code-review/comment.lua b/lua/code-review/comment.lua index 6535698..acabf9c 100644 --- a/lua/code-review/comment.lua +++ b/lua/code-review/comment.lua @@ -126,7 +126,7 @@ end ---@param comments table[] add_signs = function(bufnr, comments) local config = require("code-review.config").get("ui.signs") - + -- Remove existing signs first vim.fn.sign_unplace("CodeReviewSigns", { buffer = bufnr }) @@ -137,21 +137,21 @@ add_signs = function(bufnr, comments) 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, @@ -166,7 +166,7 @@ add_signs = function(bufnr, comments) for line = comment.line_start, comment.line_end do -- 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 state = require("code-review.state") @@ -196,7 +196,7 @@ add_signs = function(bufnr, comments) elseif status == "resolved" then sign_name = "CodeReviewResolved" end - + vim.fn.sign_place(0, "CodeReviewSigns", sign_name, bufnr, { lnum = line, priority = 100 }) end end @@ -237,19 +237,19 @@ add_virtual_text = function(bufnr, comments) else -- Single thread - find the latest comment 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 state = require("code-review.state") local thread_data = 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 + show_virt_text = false -- Don't show virtual text for resolved end if show_virt_text then @@ -264,17 +264,17 @@ add_virtual_text = function(bufnr, comments) 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) + 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 diff --git a/lua/code-review/init.lua b/lua/code-review/init.lua index 5a8e93d..88c7c80 100644 --- a/lua/code-review/init.lua +++ b/lua/code-review/init.lua @@ -243,7 +243,7 @@ function M.show_comment_at_cursor() end local thread_count = vim.tbl_count(threads) - + if thread_count == 0 then -- No threads, show all comments ui.show_comment_list(line_comments) @@ -256,7 +256,7 @@ function M.show_comment_at_cursor() -- Multiple threads, let user choose local thread_list = {} local all_threads = state.get_all_threads() - + for thread_id, comments in pairs(threads) do local thread_data = all_threads[thread_id] local thread_comments = state.get_thread_comments(thread_id) @@ -267,20 +267,24 @@ function M.show_comment_at_cursor() 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 + 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) - + 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:", @@ -435,7 +439,7 @@ function M.resolve_thread_at_cursor() -- 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] if thread_data then @@ -448,21 +452,20 @@ function M.resolve_thread_at_cursor() 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 + 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) - + 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:", diff --git a/lua/code-review/list.lua b/lua/code-review/list.lua index 49dfab2..5fe58f0 100644 --- a/lua/code-review/list.lua +++ b/lua/code-review/list.lua @@ -58,17 +58,24 @@ function M.list_with_quickfix() for _, thread_data in ipairs(sorted_threads) do local thread_info = all_threads[thread_data.id] local status_indicator = "" - + if thread_info then if thread_info.status == "resolved" then status_indicator = "[✓] " - elseif thread_info.status == "waiting-review" then - status_indicator = "[⏳] " - elseif thread_info.status == "action-required" 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 @@ -418,27 +425,29 @@ function M.list_threads_with_quickfix() 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 + + -- Get status icon (match virtual text) local status_icon = "" if root_comment.thread_status == "action-required" then - status_icon = "[!] " + status_icon = "[○] " -- Match virtual text elseif root_comment.thread_status == "waiting-review" then - status_icon = "[⏳] " + 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)", + local text = string.format( + "%s%s (%d comments)", status_icon, root_comment.comment:match("^[^\n]*") or root_comment.comment, - #thread_data.replies + 1) - + #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, @@ -466,10 +475,400 @@ function M.list_threads_with_quickfix() 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() - -- For now, only support quickfix for thread listing - -- TODO: Add telescope and fzf-lua support for 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 diff --git a/lua/code-review/storage/file.lua b/lua/code-review/storage/file.lua index 59afc38..52803d0 100644 --- a/lua/code-review/storage/file.lua +++ b/lua/code-review/storage/file.lua @@ -39,13 +39,13 @@ local function parse_filename(filename) if status and id then return status, id end - + -- 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 @@ -64,12 +64,12 @@ 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 @@ -107,7 +107,7 @@ local function get_comment_filename(comment_data, status) 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) @@ -216,7 +216,7 @@ local function parse_comment_from_file(content, filename) timestamp = parsed_timestamp, context_lines = context_lines, thread_id = frontmatter.thread_id, - thread_status = status, -- Add status from filename + thread_status = status, -- Add status from filename } elseif line == "---" and in_comments_section then -- luacheck: ignore 542 -- Comment separator, ignore @@ -369,23 +369,23 @@ function M.add(comment_data) -- 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 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() @@ -440,7 +440,7 @@ end ---@return boolean success function M.delete(id) local dir = get_storage_dir() - + -- Find file with any status prefix local files = vim.fn.glob(dir .. "/*_" .. id .. ".md", false, true) if #files > 0 then @@ -448,7 +448,7 @@ function M.delete(id) invalidate_cache() return true end - + -- Fallback for legacy format local legacy_filepath = dir .. "/" .. id .. ".md" if vim.fn.filereadable(legacy_filepath) == 1 then @@ -511,7 +511,7 @@ function M.format_comment_as_markdown(comment_data) if comment_data.thread_id then table.insert(lines, "thread_id: " .. comment_data.thread_id) end - + -- Removed: parent_id, thread_status, resolved_by, resolved_at -- Status is now derived from filename @@ -555,7 +555,7 @@ function M.get_thread(thread_id) -- 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) @@ -563,7 +563,7 @@ function M.get_thread(thread_id) status = parsed_status end end - + return { id = thread_id, status = status, @@ -587,11 +587,11 @@ end ---@return boolean success function M.update_thread_status(thread_id, status, resolved_by) local comments = load_comments() - + -- 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 table.insert(thread_comments, comment) @@ -600,11 +600,11 @@ function M.update_thread_status(thread_id, status, resolved_by) end end end - + if not root_comment then return false end - + -- Map generic status to filename status local filename_status if status == "resolved" then @@ -615,25 +615,25 @@ function M.update_thread_status(thread_id, status, resolved_by) 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 - + -- 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 true end @@ -698,7 +698,7 @@ function M.format_thread_as_markdown(thread_comments) if root_comment.thread_id then table.insert(lines, "thread_id: " .. root_comment.thread_id) end - + -- Removed: parent_id, thread_status, resolved_by, resolved_at -- Status is now derived from filename diff --git a/lua/code-review/ui.lua b/lua/code-review/ui.lua index f3956c8..5868053 100644 --- a/lua/code-review/ui.lua +++ b/lua/code-review/ui.lua @@ -479,7 +479,7 @@ function M.show_comment_list(comments) title = " Review 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) diff --git a/tests/test_filename_status.lua b/tests/test_filename_status.lua index c813b55..e057397 100644 --- a/tests/test_filename_status.lua +++ b/tests/test_filename_status.lua @@ -36,7 +36,7 @@ T["filename status management"] = MiniTest.new_set({ state._reset() state.init() state.clear() - + -- Clean up any test files vim.fn.system("rm -rf .code-review/test_*") end, @@ -127,19 +127,19 @@ T["filename status management"]["determine_thread_status returns correct status" -- Test status determination based on author local test_cases = { { - comments = {{ author = "Claude Code", comment = "Test", time = os.time() }}, + 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() }}, + 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() } + { author = "Claude Code", comment = "Reply", time = os.time() }, }, expected = "waiting-review", description = "Claude Code as latest reply should result in waiting-review", @@ -147,7 +147,7 @@ T["filename status management"]["determine_thread_status returns correct status" { comments = { { author = "Claude Code", comment = "Initial", time = os.time() - 100 }, - { author = "User", comment = "Reply", time = os.time() } + { author = "User", comment = "Reply", time = os.time() }, }, expected = "action-required", description = "User as latest reply should result in action-required", @@ -289,7 +289,7 @@ T["filename status management"]["status preserved in list view"] = function() -- Get all comments local comments = state.get_comments() - + -- Find our test comments local comment1, comment2 for _, c in ipairs(comments) do @@ -338,4 +338,4 @@ T["filename status management"]["resolve thread updates filename"] = function() MiniTest.expect.equality(#comment_resolved_files, 1) end -return T \ No newline at end of file +return T From 7084bb7a2b0643e7e5dcfbe14f653a192759ef82 Mon Sep 17 00:00:00 2001 From: Akihiro Okuno Date: Wed, 9 Jul 2025 22:23:45 +0900 Subject: [PATCH 11/12] chore: fix lint warnings --- lua/code-review/comment.lua | 8 ++++---- lua/code-review/init.lua | 8 ++++---- lua/code-review/storage/file.lua | 23 ----------------------- tests/test_filename_status.lua | 16 ---------------- 4 files changed, 8 insertions(+), 47 deletions(-) diff --git a/lua/code-review/comment.lua b/lua/code-review/comment.lua index acabf9c..deb1452 100644 --- a/lua/code-review/comment.lua +++ b/lua/code-review/comment.lua @@ -169,8 +169,8 @@ add_signs = function(bufnr, comments) -- If resolved thread, mark as resolved if comment.thread_id then - local state = require("code-review.state") - local thread_data = state.get_all_threads()[comment.thread_id] + 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 @@ -245,8 +245,8 @@ add_virtual_text = function(bufnr, comments) end -- Check if thread is resolved - local state = require("code-review.state") - local thread_data = state.get_all_threads()[thread_id] + 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 diff --git a/lua/code-review/init.lua b/lua/code-review/init.lua index 88c7c80..c359761 100644 --- a/lua/code-review/init.lua +++ b/lua/code-review/init.lua @@ -232,13 +232,13 @@ function M.show_comment_at_cursor() -- Group comments by thread local threads = {} - for _, comment in ipairs(line_comments) do - local thread_id = comment.thread_id + 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], comment) + table.insert(threads[thread_id], line_comment) end end @@ -257,7 +257,7 @@ function M.show_comment_at_cursor() local thread_list = {} local all_threads = state.get_all_threads() - for thread_id, comments in pairs(threads) do + 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 = "" diff --git a/lua/code-review/storage/file.lua b/lua/code-review/storage/file.lua index 52803d0..9956117 100644 --- a/lua/code-review/storage/file.lua +++ b/lua/code-review/storage/file.lua @@ -6,29 +6,6 @@ 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 --- Parse status from filename ---@param filename string diff --git a/tests/test_filename_status.lua b/tests/test_filename_status.lua index e057397..1f53633 100644 --- a/tests/test_filename_status.lua +++ b/tests/test_filename_status.lua @@ -1,6 +1,5 @@ local state = require("code-review.state") local file_storage = require("code-review.storage.file") -local config = require("code-review.config") -- Initialize plugin with file storage for realistic testing require("code-review").setup({ @@ -12,21 +11,6 @@ require("code-review").setup({ local T = MiniTest.new_set() --- Helper function to create a mock comment -local function create_mock_comment(opts) - opts = opts or {} - return { - id = opts.id or "1234567890", - file = opts.file or "test.lua", - line_start = opts.line_start or 1, - line_end = opts.line_end or 1, - comment = opts.comment or "Test comment", - author = opts.author or "User", - time = opts.time or os.time(), - thread_id = opts.thread_id, - parent_id = opts.parent_id, - } -end -- Setup and teardown hooks T["filename status management"] = MiniTest.new_set({ From 3c124246828b0beb02a69dca7d60768fb63795dd Mon Sep 17 00:00:00 2001 From: Akihiro Okuno Date: Wed, 9 Jul 2025 23:23:27 +0900 Subject: [PATCH 12/12] style: fix formatting issues --- lua/code-review/storage/file.lua | 1 - tests/test_filename_status.lua | 1 - 2 files changed, 2 deletions(-) diff --git a/lua/code-review/storage/file.lua b/lua/code-review/storage/file.lua index 9956117..8b1a735 100644 --- a/lua/code-review/storage/file.lua +++ b/lua/code-review/storage/file.lua @@ -6,7 +6,6 @@ local storage_dir = nil local comments_cache = nil local cache_timestamp = 0 - --- Parse status from filename ---@param filename string ---@return string status, string id diff --git a/tests/test_filename_status.lua b/tests/test_filename_status.lua index 1f53633..79e99f7 100644 --- a/tests/test_filename_status.lua +++ b/tests/test_filename_status.lua @@ -11,7 +11,6 @@ require("code-review").setup({ local T = MiniTest.new_set() - -- Setup and teardown hooks T["filename status management"] = MiniTest.new_set({ hooks = {