Skip to content

直感的なサイドパネルの実装#109

Merged
flexphere merged 8 commits intomainfrom
feat/sidepanel
Mar 28, 2026
Merged

直感的なサイドパネルの実装#109
flexphere merged 8 commits intomainfrom
feat/sidepanel

Conversation

@flexphere
Copy link
Copy Markdown
Owner

@flexphere flexphere commented Mar 28, 2026

概要

quickfix/Telescope をあまり使用していないユーザー向けに、Review Scope と Changed Files を一覧表示するトグル可能なサイドパネルを実装。

Closes #108

変更内容

  • lua/fude/ui/sidepanel.lua を新規作成: トグル可能なサイドバー分割ウィンドウ
    • Review Scope セクション: PR全体 + 個別コミット一覧(現在スコープ マーカー、レビュー済み 表示)
    • Files セクション: 変更ファイル一覧(viewed/status/additions/deletions 表示)
    • キーマップ: <CR> スコープ選択/ファイルオープン、<Tab> reviewed/viewed トグル、R リロード、q 閉じる
    • 純粋関数を分離: format_scope_section, format_files_section, build_sidepanel_content, resolve_entry_at_cursor
    • nowrap 設定により長いテキストは横スクロールで全文確認可能
    • 専用 namespace fude_sidepanel で BufEnter 時の refresh_extmarks によるハイライト消去を回避
    • 非同期コールバック(viewed トグル)にセッション identity ガード追加
  • FudeReviewPanel コマンドを追加
  • config.defaultssidepanel オプション(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

flexphere and others added 3 commits March 28, 2026 22:16
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>
Copilot AI review requested due to automatic review settings March 28, 2026 13:18
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>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.sidepanelstate.sidepanel を追加
doc/fude.txt :FudeReviewPanelsidepanel.* オプションを追記
CLAUDE.md モジュール説明と state 依存表に sidepanel を追記

Comment on lines +238 to +246
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
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

render() で diff.get_repo_root() が nil の場合に repo_root を "" にフォールバックしていますが、files.build_file_entries() は filename = repo_root .. "/" .. path を前提としているため、"/lua/..." のような誤った絶対パスを生成してしまいます(ファイルオープンも壊れます)。files.lua と同様に repo_root が取得できない場合は早期 return するか、少なくとも filename を作らない/オープン不可として扱うようにしてください。

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3dc270b で修正 — repo_root が nil の場合 file_entries を空にして誤ったパス生成を防止。scope セクションは引き続き表示します。

Comment on lines +82 to +90
-- 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
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

format_path_fn は nil の場合を許容し、また戻り値が string 以外/ nil の場合もフォールバックしていますが、型注釈が fun(s: string): string になっていて実装・テストと矛盾しています。fun(s: string): string|nil とし、引数自体も optional(format_path_fn?)として注釈を合わせておくと意図が明確になります。

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

この追記で fude_sidepanel namespace を使う旨が明記されていますが、下の「Key Patterns」の Namespace 説明は「全体で単一 namespace fude を使う」となったままで矛盾します。どちらが正かを整理し、少なくとも sidepanel の例外(または方針変更)を Key Patterns 側にも反映してください。

Suggested change
- **`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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5e8651a で修正 — Key Patterns の Namespace 記述を更新し、メインの fude namespace と専用 namespace(fude_sidepanel, fude_inline_hint, fude_refs, fude_scope_preview)の併用を明記しました。既に複数の専用 namespace が使われているため、fude namespace への統一は refresh_extmarks との衝突を招くため採用しませんでした。

flexphere and others added 2 commits March 28, 2026 22:33
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>
@flexphere flexphere marked this pull request as ready for review March 28, 2026 13:34
Copilot AI review requested due to automatic review settings March 28, 2026 13:34
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

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)")
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

M.close()nvim_win_close() 失敗時(例: サイドパネルだけが残って「最後のウィンドウ」になっている場合の "Cannot close last window")にエラーで落ちうるため、vim.cmd(...)pcall で保護するか、最後のウィンドウ時は enew 等でバッファを差し替えて“閉じた扱い”にするなど、例外なくクリーンアップできるようにしてください。

Suggested change
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

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fcaf261 で修正 — pcall で保護し、最後のウィンドウの場合は enew でフォールバックするようにしました。

Comment on lines +279 to +417
--- 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
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

open/close/toggle/refresh やキーマップ(<CR>, <Tab>, q, R)は副作用が大きい一方で、現状テストは pure 関数(format/build/resolve)のみなので、最低限「パネルを開ける/閉じられる」「close 後に state.sidepanel が消える」などの integration テストを追加しておくと回帰を検知しやすいです。

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5bb8336 で追加 — open/close/toggle/refresh/cursor保持/再open 等 13件の integration テストを追加しました。

flexphere and others added 2 commits March 28, 2026 23:38
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>
@flexphere flexphere merged commit 4e950f1 into main Mar 28, 2026
4 checks passed
@flexphere flexphere deleted the feat/sidepanel branch March 28, 2026 14:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

直感的なサイドパネルの実装

2 participants