Conversation
Add FudeReviewPanel command that toggles a sidebar split window showing Review Scope and Changed Files sections. Supports scope selection, file opening, reviewed/viewed toggling, and auto-refresh on scope change and data reload. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
33 tests covering format_scope_section, format_files_section, build_sidepanel_content, and resolve_entry_at_cursor including edge cases for empty entries, CJK truncation, and cursor mapping. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Document the new side panel command, keymaps, configuration options (sidepanel.width, sidepanel.position), and update CLAUDE.md architecture section with module description and state dependencies. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The sidepanel already uses nowrap, so users can scroll horizontally to see full text. Truncation was unnecessarily hiding information. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
quickfix/Telescope をあまり使わないユーザー向けに、Review Scope と Changed Files を操作できるトグル式サイドパネルを追加し、レビュー操作の導線を改善するPRです。
Changes:
- トグル可能なサイドパネル UI(Scope / Files 表示、キーマップ、描画/更新ロジック、表示幅トランケーション)を追加
FudeReviewPanelコマンド追加と、scope 変更・reload・stop に追随した自動リフレッシュ/クリーンアップを追加- 設定(
sidepanel.width/position)とヘルプ/開発ドキュメント、ユニットテストを追加
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/fude/sidepanel_spec.lua | 純粋関数(フォーマット/マッピング/解決)のユニットテスト追加 |
| plugin/fude.lua | :FudeReviewPanel コマンド追加 |
| lua/fude/ui/sidepanel.lua | サイドパネル本体(描画・操作・更新・トグル、純粋関数群)を新規実装 |
| lua/fude/scope.lua | scope 適用時に sidepanel を refresh |
| lua/fude/init.lua | stop 時の sidepanel close、reload 完了時の sidepanel refresh |
| lua/fude/config.lua | defaults.sidepanel と state.sidepanel を追加 |
| doc/fude.txt | :FudeReviewPanel と sidepanel.* オプションを追記 |
| CLAUDE.md | モジュール説明と state 依存表に sidepanel を追記 |
| vim.bo[buf].modifiable = false | ||
|
|
||
| -- Apply highlights (using dedicated namespace to avoid refresh_extmarks clearing them) | ||
| vim.api.nvim_buf_clear_namespace(buf, sidepanel_ns, 0, -1) | ||
| for _, hl in ipairs(highlights) do | ||
| pcall(vim.api.nvim_buf_add_highlight, buf, sidepanel_ns, hl[4], hl[1], hl[2], hl[3]) | ||
| end | ||
|
|
||
| -- Store entries and map for keymap handlers |
There was a problem hiding this comment.
render() で diff.get_repo_root() が nil の場合に repo_root を "" にフォールバックしていますが、files.build_file_entries() は filename = repo_root .. "/" .. path を前提としているため、"/lua/..." のような誤った絶対パスを生成してしまいます(ファイルオープンも壊れます)。files.lua と同様に repo_root が取得できない場合は早期 return するか、少なくとも filename を作らない/オープン不可として扱うようにしてください。
There was a problem hiding this comment.
3dc270b で修正 — repo_root が nil の場合 file_entries を空にして誤ったパス生成を防止。scope セクションは引き続き表示します。
| -- Viewed icon highlight | ||
| table.insert(highlights, { line_idx, 1, 1 + #viewed, entry.viewed_hl or "Comment" }) | ||
| -- Status icon highlight | ||
| local status_start = 1 + #viewed + 1 | ||
| table.insert(highlights, { line_idx, status_start, status_start + #status, entry.status_hl or "DiffChange" }) | ||
| -- Additions highlight | ||
| local adds_start = status_start + #status + 1 | ||
| table.insert(highlights, { line_idx, adds_start, adds_start + #adds, "DiffAdd" }) | ||
| -- Deletions highlight |
There was a problem hiding this comment.
format_path_fn は nil の場合を許容し、また戻り値が string 以外/ nil の場合もフォールバックしていますが、型注釈が fun(s: string): string になっていて実装・テストと矛盾しています。fun(s: string): string|nil とし、引数自体も optional(format_path_fn?)として注釈を合わせておくと意図が明確になります。
There was a problem hiding this comment.
3dc270b で修正 — 型注釈を (fun(s: string): string|nil)|nil に更新しました。
| - **`ui.lua`** — Facade module re-exporting `ui/format.lua` and `ui/extmarks.lua`. Contains floating window UI: comment input editor, comment viewer, PR overview window, reply window, edit window, and review event selector. `require("fude.ui")` is the public interface. | ||
| - **`ui/format.lua`** — Pure format/calculation functions with no state or vim API side effects: `calculate_float_dimensions`, `format_comments_for_display`, `normalize_check`, `format_check_status`, `deduplicate_checks`, `sort_checks`, `build_checks_summary`, `format_review_status`, `build_reviewers_list`, `build_reviewers_summary`, `calculate_overview_layout`, `calculate_comments_height`, `calculate_reply_window_dimensions`, `format_reply_comments_for_display`, `build_overview_left_lines`, `build_overview_right_lines`, `calculate_comment_browser_layout`, `format_comment_browser_list`, `format_comment_browser_thread`, `parse_markdown_line`, `build_highlighted_chunks`, `apply_markdown_highlight_to_line`. | ||
| - **`ui/comment_browser.lua`** — 3-pane floating comment browser for `FudeReviewListComments`. Left pane: comment list (review + PR-level, time-descending). Right upper: thread display. Right lower: reply/edit/new comment input. Supports reply, edit, delete, new PR comment, jump to file, and refresh. Does not depend on Telescope. | ||
| - **`ui/sidepanel.lua`** — Toggleable sidebar showing Review Scope and Changed Files. Pure functions: `format_scope_section`, `format_files_section`, `build_sidepanel_content`, `resolve_entry_at_cursor`. Side-effect functions: `open`, `close`, `toggle`, `refresh`. Uses `nvim_open_win` with `split` for sidebar creation. Uses dedicated `fude_sidepanel` namespace for highlights (avoids `refresh_extmarks` clearing them on BufEnter). Auto-refreshes on scope change and reload. Keymaps: `<CR>` select/open, `<Tab>` toggle reviewed/viewed, `R` reload, `q` close. |
There was a problem hiding this comment.
この追記で fude_sidepanel namespace を使う旨が明記されていますが、下の「Key Patterns」の Namespace 説明は「全体で単一 namespace fude を使う」となったままで矛盾します。どちらが正かを整理し、少なくとも sidepanel の例外(または方針変更)を Key Patterns 側にも反映してください。
| - **`ui/sidepanel.lua`** — Toggleable sidebar showing Review Scope and Changed Files. Pure functions: `format_scope_section`, `format_files_section`, `build_sidepanel_content`, `resolve_entry_at_cursor`. Side-effect functions: `open`, `close`, `toggle`, `refresh`. Uses `nvim_open_win` with `split` for sidebar creation. Uses dedicated `fude_sidepanel` namespace for highlights (avoids `refresh_extmarks` clearing them on BufEnter). Auto-refreshes on scope change and reload. Keymaps: `<CR>` select/open, `<Tab>` toggle reviewed/viewed, `R` reload, `q` close. | |
| - **`ui/sidepanel.lua`** — Toggleable sidebar showing Review Scope and Changed Files. Pure functions: `format_scope_section`, `format_files_section`, `build_sidepanel_content`, `resolve_entry_at_cursor`. Side-effect functions: `open`, `close`, `toggle`, `refresh`. Uses `nvim_open_win` with `split` for sidebar creation. Uses the shared `fude` namespace for highlights, consistent with other UI modules. Auto-refreshes on scope change and reload. Keymaps: `<CR>` select/open, `<Tab>` toggle reviewed/viewed, `R` reload, `q` close. |
There was a problem hiding this comment.
5e8651a で修正 — Key Patterns の Namespace 記述を更新し、メインの fude namespace と専用 namespace(fude_sidepanel, fude_inline_hint, fude_refs, fude_scope_preview)の併用を明記しました。既に複数の専用 namespace が使われているため、fude namespace への統一は refresh_extmarks との衝突を招くため採用しませんでした。
Skip file entries when repo_root is unavailable instead of passing empty string which generates incorrect absolute paths. Also fix format_path_fn type annotation to match nil-accepting implementation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Reflect the actual multi-namespace pattern: main "fude" namespace for comment extmarks plus dedicated namespaces (fude_sidepanel, fude_inline_hint, fude_refs, fude_scope_preview) where isolation is needed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
lua/fude/ui/sidepanel.lua
Outdated
| end | ||
|
|
||
| if panel.win and vim.api.nvim_win_is_valid(panel.win) then | ||
| vim.cmd("noautocmd call nvim_win_close(" .. panel.win .. ", v:true)") |
There was a problem hiding this comment.
M.close() が nvim_win_close() 失敗時(例: サイドパネルだけが残って「最後のウィンドウ」になっている場合の "Cannot close last window")にエラーで落ちうるため、vim.cmd(...) を pcall で保護するか、最後のウィンドウ時は enew 等でバッファを差し替えて“閉じた扱い”にするなど、例外なくクリーンアップできるようにしてください。
| vim.cmd("noautocmd call nvim_win_close(" .. panel.win .. ", v:true)") | |
| -- nvim_win_close() が "Cannot close last window" などで失敗しても | |
| -- ここで例外を外に出さず、後続のクリーンアップまで到達できるようにする。 | |
| local ok, err = pcall( | |
| vim.cmd, | |
| "noautocmd call nvim_win_close(" .. panel.win .. ", v:true)" | |
| ) | |
| if not ok and type(err) == "string" and err:match("Cannot close last window") then | |
| -- 最後のウィンドウは閉じられないので、バッファだけ差し替えて | |
| -- 「サイドパネルを閉じた」状態に近づける。 | |
| pcall(vim.cmd, "enew") | |
| end |
There was a problem hiding this comment.
fcaf261 で修正 — pcall で保護し、最後のウィンドウの場合は enew でフォールバックするようにしました。
| --- Open the sidepanel. | ||
| function M.open() | ||
| local state = config.state | ||
| if not state.active then | ||
| vim.notify("fude.nvim: Not active", vim.log.levels.WARN) | ||
| return | ||
| end | ||
|
|
||
| -- Close existing panel | ||
| M.close() | ||
|
|
||
| local sp_opts = config.opts.sidepanel or {} | ||
| local width = math.max(20, sp_opts.width or 40) | ||
| local position = sp_opts.position or "left" | ||
|
|
||
| -- Create buffer | ||
| local buf = vim.api.nvim_create_buf(false, true) | ||
| vim.bo[buf].buftype = "nofile" | ||
| vim.bo[buf].bufhidden = "wipe" | ||
| vim.bo[buf].modifiable = false | ||
|
|
||
| -- Create split window | ||
| local split_dir = position == "right" and "right" or "left" | ||
| local win = vim.api.nvim_open_win(buf, true, { | ||
| split = split_dir, | ||
| width = width, | ||
| }) | ||
|
|
||
| vim.wo[win].number = false | ||
| vim.wo[win].relativenumber = false | ||
| vim.wo[win].signcolumn = "no" | ||
| vim.wo[win].winfixwidth = true | ||
| vim.wo[win].cursorline = true | ||
| vim.wo[win].wrap = false | ||
| vim.wo[win].foldcolumn = "0" | ||
| vim.wo[win].spell = false | ||
| vim.wo[win].list = false | ||
|
|
||
| pcall(vim.api.nvim_buf_set_name, buf, "[fude] Panel") | ||
|
|
||
| -- Save state | ||
| local panel = { | ||
| win = win, | ||
| buf = buf, | ||
| scope_entries = {}, | ||
| file_entries = {}, | ||
| section_map = nil, | ||
| augroup = nil, | ||
| } | ||
| state.sidepanel = panel | ||
|
|
||
| -- WinClosed autocmd | ||
| local augroup = vim.api.nvim_create_augroup("fude_sidepanel_" .. win, { clear = true }) | ||
| panel.augroup = augroup | ||
| vim.api.nvim_create_autocmd("WinClosed", { | ||
| group = augroup, | ||
| callback = function(ev) | ||
| local closed_win = tonumber(ev.match) | ||
| if closed_win == win then | ||
| M.close() | ||
| end | ||
| end, | ||
| }) | ||
|
|
||
| -- Render content | ||
| render(panel) | ||
|
|
||
| -- Place cursor on first scope entry | ||
| if panel.section_map then | ||
| pcall(vim.api.nvim_win_set_cursor, win, { panel.section_map.scope_start + 1, 0 }) | ||
| end | ||
|
|
||
| -- Setup keymaps | ||
| M.setup_keymaps(panel) | ||
| end | ||
|
|
||
| --- Toggle the sidepanel open or closed. | ||
| function M.toggle() | ||
| local panel = config.state.sidepanel | ||
| if panel and panel.win and vim.api.nvim_win_is_valid(panel.win) then | ||
| M.close() | ||
| else | ||
| M.open() | ||
| end | ||
| end | ||
|
|
||
| --- Setup keymaps for the sidepanel buffer. | ||
| --- @param panel table sidepanel state | ||
| function M.setup_keymaps(panel) | ||
| local buf = panel.buf | ||
|
|
||
| -- Close | ||
| vim.keymap.set("n", "q", function() | ||
| M.close() | ||
| end, { buffer = buf, desc = "Close side panel" }) | ||
|
|
||
| -- Refresh (reload from GitHub) | ||
| vim.keymap.set("n", "R", function() | ||
| local init_mod = require("fude.init") | ||
| init_mod.reload() | ||
| end, { buffer = buf, desc = "Reload review data" }) | ||
|
|
||
| -- Select / Open | ||
| vim.keymap.set("n", "<CR>", function() | ||
| local entry_info = M.get_current_entry(panel) | ||
| if not entry_info then | ||
| return | ||
| end | ||
|
|
||
| if entry_info.type == "scope" then | ||
| local scope_mod = get_scope() | ||
| scope_mod.apply_scope(entry_info.entry) | ||
| elseif entry_info.type == "file" then | ||
| local filename = entry_info.entry.filename | ||
| if filename then | ||
| -- Move to a non-panel window before opening the file | ||
| local target_win = M.find_target_window(panel.win) | ||
| if target_win then | ||
| vim.api.nvim_set_current_win(target_win) | ||
| end | ||
| vim.cmd("edit " .. vim.fn.fnameescape(filename)) | ||
| end | ||
| end | ||
| end, { buffer = buf, desc = "Select scope or open file" }) | ||
|
|
||
| -- Tab: toggle reviewed/viewed | ||
| vim.keymap.set("n", "<Tab>", function() | ||
| local entry_info = M.get_current_entry(panel) | ||
| if not entry_info then | ||
| return | ||
| end | ||
|
|
||
| if entry_info.type == "scope" then | ||
| M.toggle_scope_reviewed(panel, entry_info) | ||
| elseif entry_info.type == "file" then | ||
| M.toggle_file_viewed(panel, entry_info) | ||
| end | ||
| end, { buffer = buf, desc = "Toggle reviewed/viewed" }) | ||
| end |
There was a problem hiding this comment.
open/close/toggle/refresh やキーマップ(<CR>, <Tab>, q, R)は副作用が大きい一方で、現状テストは pure 関数(format/build/resolve)のみなので、最低限「パネルを開ける/閉じられる」「close 後に state.sidepanel が消える」などの integration テストを追加しておくと回帰を検知しやすいです。
There was a problem hiding this comment.
5bb8336 で追加 — open/close/toggle/refresh/cursor保持/再open 等 13件の integration テストを追加しました。
Wrap nvim_win_close in pcall to handle "Cannot close last window" error. Falls back to enew when the sidepanel is the only window. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
13 tests covering open, close, toggle, refresh, cursor preservation, content shrink clamping, re-open behavior, and state population. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
概要
quickfix/Telescope をあまり使用していないユーザー向けに、Review Scope と Changed Files を一覧表示するトグル可能なサイドパネルを実装。
Closes #108
変更内容
lua/fude/ui/sidepanel.luaを新規作成: トグル可能なサイドバー分割ウィンドウ▶マーカー、レビュー済み✓表示)<CR>スコープ選択/ファイルオープン、<Tab>reviewed/viewed トグル、Rリロード、q閉じるformat_scope_section,format_files_section,build_sidepanel_content,resolve_entry_at_cursorfude_sidepanelで BufEnter 時のrefresh_extmarksによるハイライト消去を回避FudeReviewPanelコマンドを追加config.defaultsにsidepanelオプション(width,position)を追加init.stop()でサイドパネルをクリーンアップinit.reload()完了時・scope.apply_*_scope()完了時にサイドパネルを自動リフレッシュテスト計画
make all)tests/fude/sidepanel_spec.lua(31テスト)FudeReviewPanelでパネル表示→ファイル選択→パネルに戻る→ハイライト保持備考
editを実行するため、パネルは開いたまま維持されるscope.build_scope_entries()とfiles.build_file_entries()を再利用しており、新規データ取得ロジックは追加していないGenerated with Claude Code