|
| 1 | +# レビュー画面の可読性改善設計書 |
| 2 | + |
| 3 | +> **ステータス**: 設計完了(2026-04-09) |
| 4 | +> |
| 5 | +> PR詳細画面のdiff表示に行番号ガターと変更行ジャンプ(`]c`/`[c`)を追加し、コードレビュー体験を改善する。 |
| 6 | +
|
| 7 | +## 概要 |
| 8 | + |
| 9 | +PR詳細画面(`PullRequestDetail`)のdiff表示に2つの機能を追加し、コードレビュー体験を改善する: |
| 10 | +1. 行番号ガターの表示 |
| 11 | +2. 変更行への高速ジャンプナビゲーション(`]c`/`[c`) |
| 12 | + |
| 13 | +## Context |
| 14 | + |
| 15 | +ユーザーがレビュー画面を使用中に「見づらい」と感じている。具体的には: |
| 16 | +1. **diffに行番号がない** — コードの位置が分からず、レビューコメントで行を参照できない |
| 17 | +2. **変更行にすぐ飛べない** — `j`/`k`で1行ずつ移動するか、`n`/`N`でファイル間ジャンプしかできず、変更箇所(add/delete行)に素早く移動する手段がない |
| 18 | + |
| 19 | +本設計は v0.3.0(UX強化: diff可読性・ナビゲーション向上)ロードマップに合致する。 |
| 20 | + |
| 21 | +## ファイル構成 |
| 22 | + |
| 23 | +| ファイル | 変更種別 | Iteration | |
| 24 | +|---------|---------|-----------| |
| 25 | +| `src/components/DiffLine.tsx` | 変更: 行番号ガター追加 | 1 | |
| 26 | +| `src/components/PullRequestDetail.tsx` | 変更: `]c`/`[c` キーバインド、`pendingBracket` state追加 | 2 | |
| 27 | +| `src/components/Help.tsx` | 変更: Navigation セクションに `]c`/`[c` 追加 | 2 | |
| 28 | +| `src/components/PullRequestDetail.test.tsx` | 変更: テストケース追加 | 1, 2 | |
| 29 | + |
| 30 | +## コンポーネント設計 |
| 31 | + |
| 32 | +### Iteration 1: diff行番号の表示 |
| 33 | + |
| 34 | +**Tidy First?**: 不要。`DiffLine.tsx` はシンプルな switch-case で読みやすく、構造変更なしに機能を追加できる。 |
| 35 | + |
| 36 | +**対象ファイル:** |
| 37 | +- `src/components/DiffLine.tsx` — 行番号ガターのレンダリング追加 |
| 38 | +- `src/components/PullRequestDetail.test.tsx` — テスト追加 |
| 39 | + |
| 40 | +**設計:** |
| 41 | + |
| 42 | +`renderDiffLine` に行番号ガターを追加する。`DisplayLine` には既に `beforeLineNumber` と `afterLineNumber` が計算済み(`formatDiff.ts:55-56,77,94`)なので、表示するだけ。 |
| 43 | + |
| 44 | +表示形式(ガター幅: 12文字 = 4 + 1 + 4 + 3): |
| 45 | +``` |
| 46 | + 3 4 │ context line text |
| 47 | + 5 │ -deleted line text |
| 48 | + 6 │ +added line text |
| 49 | +``` |
| 50 | + |
| 51 | +- before/after それぞれ4文字幅(`padStart(4)`)で右寄せ、スペース1文字区切り、`│ ` セパレータ付き |
| 52 | +- `<Text dimColor>` で表示し、diff本文の色(green/red/white)と競合しないようにする |
| 53 | +- `add`, `delete`, `context` 行のみガター表示 |
| 54 | +- `header`, `separator`, `truncation`, `truncate-context`, `comment-header`, `comment`, `inline-comment`, `inline-reply`, `comment-reply`, `fold-indicator` はガター不要(行番号プロパティが存在しないため) |
| 55 | +- ターミナル幅80カラムの場合: カーソル2文字 + ガター12文字 = 14文字消費、残り66文字がdiff本文(十分) |
| 56 | +- 行番号が9999を超える場合: `padStart(4)` では左にはみ出すが、レイアウトが壊れるわけではなく数字が左に1文字伸びるだけ。10,000行超のファイルは稀であり、対応不要 |
| 57 | + |
| 58 | +**実装詳細:** |
| 59 | + |
| 60 | +`DiffLine.tsx` に `formatGutter(line: DisplayLine): string` ヘルパーを追加: |
| 61 | +```typescript |
| 62 | +function formatGutter(line: DisplayLine): string { |
| 63 | + const before = line.beforeLineNumber ? String(line.beforeLineNumber).padStart(4) : " "; |
| 64 | + const after = line.afterLineNumber ? String(line.afterLineNumber).padStart(4) : " "; |
| 65 | + return `${before} ${after} │ `; |
| 66 | +} |
| 67 | +``` |
| 68 | + |
| 69 | +`add`, `delete`, `context` の各caseでガター部分を `<Text dimColor>` で先頭に追加: |
| 70 | +```tsx |
| 71 | +case "context": |
| 72 | + return ( |
| 73 | + <Text bold={bold}> |
| 74 | + <Text dimColor>{formatGutter(line)}</Text> |
| 75 | + {line.text} |
| 76 | + </Text> |
| 77 | + ); |
| 78 | +case "add": |
| 79 | + return ( |
| 80 | + <Text color="green" bold={bold}> |
| 81 | + <Text dimColor>{formatGutter(line)}</Text> |
| 82 | + {line.text} |
| 83 | + </Text> |
| 84 | + ); |
| 85 | +case "delete": |
| 86 | + return ( |
| 87 | + <Text color="red" bold={bold}> |
| 88 | + <Text dimColor>{formatGutter(line)}</Text> |
| 89 | + {line.text} |
| 90 | + </Text> |
| 91 | + ); |
| 92 | +``` |
| 93 | + |
| 94 | +**データフロー:** |
| 95 | +``` |
| 96 | +formatDiff.ts: computeSimpleDiff() |
| 97 | + → DisplayLine { beforeLineNumber: 3, afterLineNumber: 4, type: "context", text: " line1" } |
| 98 | + → displayLines.ts: buildDisplayLines() でそのまま伝搬 |
| 99 | + → PullRequestDetail.tsx: renderDiffLine(line, isCursor) を呼び出し |
| 100 | + → DiffLine.tsx: formatGutter(line) で " 3 4 │ " を生成、<Text dimColor> で表示 |
| 101 | +``` |
| 102 | + |
| 103 | +--- |
| 104 | + |
| 105 | +### Iteration 2: 変更行ジャンプ機能の追加 (`]c`/`[c`) |
| 106 | + |
| 107 | +**Tidy First?**: 不要。`useInput` ハンドラは既存のパターン(キー検出 → 状態更新 → return)で一貫しており、新キーバインドを同パターンで追加可能。 |
| 108 | + |
| 109 | +**対象ファイル:** |
| 110 | +- `src/components/PullRequestDetail.tsx` — `]c`/`[c` キーバインド追加 |
| 111 | +- `src/components/PullRequestDetail.test.tsx` — テスト追加 |
| 112 | +- `src/components/Help.tsx` — ヘルプ画面にキー説明追加 |
| 113 | + |
| 114 | +**設計:** |
| 115 | + |
| 116 | +Vim diff風の `]c` (次の変更行) / `[c` (前の変更行) キーバインドを追加する。 |
| 117 | + |
| 118 | +変更行 = `line.type === "add" || line.type === "delete"` の行 |
| 119 | + |
| 120 | +**2キーシーケンスの設計判断:** |
| 121 | + |
| 122 | +`design-review-keybindings.md:349` で「2ストローク操作はタイムアウトベースのキーシーケンス管理が必要」と記載されている。しかしこれは `gg` のように**1キー目が既存キーバインドと衝突する場合**(`g` はリアクション操作に使用中)の話。`[`/`]` は現在どのキーバインドにも使用されていないため、押下時点で即座に `pendingBracket` state を設定する state ベースアプローチが使える。タイムアウト管理は不要。 |
| 123 | + |
| 124 | +**state遷移図:** |
| 125 | +``` |
| 126 | +[null] ──("]"キー)──→ ["]"] ──("c"キー)──→ [null] + 次の変更行へジャンプ |
| 127 | + ↑ │ |
| 128 | + └──(他のキー)─────────┘ (リセット + そのキーを通常処理) |
| 129 | +
|
| 130 | +[null] ──("["キー)──→ ["["] ──("c"キー)──→ [null] + 前の変更行へジャンプ |
| 131 | + ↑ │ |
| 132 | + └──(他のキー)─────────┘ (リセット + そのキーを通常処理) |
| 133 | +``` |
| 134 | + |
| 135 | +**実装詳細:** |
| 136 | + |
| 137 | +1. `pendingBracket` state を追加(`PullRequestDetail.tsx`、既存state群の近くに配置): |
| 138 | +```typescript |
| 139 | +const [pendingBracket, setPendingBracket] = useState<"[" | "]" | null>(null); |
| 140 | +``` |
| 141 | + |
| 142 | +2. `useInput` コールバック内で、**モーダルガード(line 405-416)の直後、既存キーハンドラの前**に配置: |
| 143 | + |
| 144 | +```typescript |
| 145 | +// === 2-key sequence: ]c / [c for change navigation === |
| 146 | +if (pendingBracket && input === "c") { |
| 147 | + const isNext = pendingBracket === "]"; |
| 148 | + setPendingBracket(null); |
| 149 | + if (isNext) { |
| 150 | + const idx = lines.findIndex( |
| 151 | + (l, i) => i > cursorIndex && (l.type === "add" || l.type === "delete"), |
| 152 | + ); |
| 153 | + if (idx !== -1) setCursorIndex(idx); |
| 154 | + } else { |
| 155 | + for (let i = cursorIndex - 1; i >= 0; i--) { |
| 156 | + if (lines[i]!.type === "add" || lines[i]!.type === "delete") { |
| 157 | + setCursorIndex(i); |
| 158 | + break; |
| 159 | + } |
| 160 | + } |
| 161 | + } |
| 162 | + return; |
| 163 | +} |
| 164 | +if (pendingBracket) { |
| 165 | + setPendingBracket(null); // 無効な2キー目: リセットしてフォールスルー |
| 166 | +} |
| 167 | +``` |
| 168 | + |
| 169 | +3. 既存キーハンドラの末尾付近(`G` ハンドラ等の後)に `[`/`]` キャプチャを追加: |
| 170 | +```typescript |
| 171 | +if (input === "]") { |
| 172 | + setPendingBracket("]"); |
| 173 | + return; |
| 174 | +} |
| 175 | +if (input === "[") { |
| 176 | + setPendingBracket("["); |
| 177 | + return; |
| 178 | +} |
| 179 | +``` |
| 180 | + |
| 181 | +## エッジケース・異常系 |
| 182 | + |
| 183 | +| ケース | 挙動 | 根拠 | |
| 184 | +|--------|------|------| |
| 185 | +| カーソルが既に変更行上 | 次の/前の**別の**変更行にジャンプ | `i > cursorIndex` / `i < cursorIndex` で現在行を除外 | |
| 186 | +| 前方/後方に変更行がない | カーソル位置変更なし | `findIndex` が `-1` を返す / for ループが `break` せず終了。ラップアラウンドしない(Vimの `]c` と同じ) | |
| 187 | +| diffが空(ファイルロード中) | カーソル位置変更なし | `lines` にadd/delete行が存在しない | |
| 188 | +| モーダル表示中に `[` を押下 | `pendingBracket` は設定されない | モーダルガード(line 405-416)で早期リターンするため | |
| 189 | +| `[` 押下直後に `j` を押す | pendingBracket リセット、`j` は通常のカーソル移動 | `pendingBracket` チェックで null 化後、return せずフォールスルー | |
| 190 | +| `[` 押下直後に `q` を押す | pendingBracket リセット、`q` で画面遷移 | 同上 | |
| 191 | +| All changes / コミットビュー | 両方で動作する | diff行のtype判定は共通。`viewIndex` によるガードなし | |
| 192 | +| 連続して `]c`/`[c` を押す | 変更行を順にたどれる | 毎回 `cursorIndex` から検索するため | |
| 193 | + |
| 194 | +**キーバインドの整合性:** |
| 195 | + |
| 196 | +`c` は既にコメント投稿に使用中(line 519-522)。衝突しない理由: |
| 197 | +- `[` 押下 → `pendingBracket` が `"["` にセット、即 return |
| 198 | +- 次の入力 `c` → `pendingBracket` チェック(モーダルガード直後に配置)が先に発火し、変更行ジャンプ実行 |
| 199 | +- `c` 単体押下(`pendingBracket` が null)→ pendingBracket チェック条件 `if (pendingBracket && input === "c")` が false → 従来通り line 519 のコメント投稿ハンドラに到達 |
| 200 | + |
| 201 | +**フッター更新** (`PullRequestDetail.tsx` line 905付近): |
| 202 | +`n/N file` の直後に `]c/[c change` を追加。 |
| 203 | + |
| 204 | +**Help.tsx更新:** |
| 205 | +Navigation セクション内(line 50-51 の `n`/`N` の直後)に追加: |
| 206 | +```tsx |
| 207 | +<KeyRow keyName="]c" description="Next change (add/delete line)" /> |
| 208 | +<KeyRow keyName="[c" description="Previous change (add/delete line)" /> |
| 209 | +``` |
| 210 | + |
| 211 | +## テスト戦略 |
| 212 | + |
| 213 | +### Iteration 1: 行番号テスト |
| 214 | + |
| 215 | +TDDサイクル: |
| 216 | +1. **Red**: `PullRequestDetail.test.tsx` にテスト追加 — diff行に `│` セパレータが含まれることを検証 |
| 217 | +2. **Green**: `DiffLine.tsx` に `formatGutter` + 各caseの変更を実装 |
| 218 | +3. **Refactor**: 必要に応じて整理 |
| 219 | + |
| 220 | +**既存テストへの影響:** |
| 221 | +- `toContain("line1")` (line 251): ガターが前置されても `line1` は含まれるためパス |
| 222 | +- `toMatch(/> .*new line 1/)` (line 1938): `.*` がガター文字にマッチするためパス |
| 223 | +- `toContain("src/auth.ts")` (line 225): header行にはガターが付かないためパス |
| 224 | + |
| 225 | +### Iteration 2: 変更行ジャンプテスト |
| 226 | + |
| 227 | +TDDサイクル: |
| 228 | +1. **Red**: テストを追加 |
| 229 | + - `]c` で次のadd/delete行にジャンプすることを確認 |
| 230 | + - `[c` で前のadd/delete行にジャンプすることを確認 |
| 231 | + - 変更行がない場合はカーソル位置が変わらないことを確認 |
| 232 | + - `[` + `j` でpendingBracketがリセットされ、`j` が通常処理されることを確認 |
| 233 | +2. **Green**: `PullRequestDetail.tsx` に pendingBracket state + useInput ハンドラ実装、`Help.tsx` にキー説明追加 |
| 234 | +3. **Refactor**: 必要に応じて整理 |
| 235 | + |
| 236 | +## 実装計画 |
| 237 | + |
| 238 | +各イテレーション完了時に `bun run ci` を実行して全チェックをパス: |
| 239 | +1. `lint` — oxlint |
| 240 | +2. `format:check` — Biome formatting |
| 241 | +3. `check` — TypeScript type check |
| 242 | +4. `dead-code` — knip unused export detection |
| 243 | +5. `audit` — dependency vulnerability check |
| 244 | +6. `test:coverage` — Vitest with 95% coverage threshold |
| 245 | +7. `build` — production build |
| 246 | + |
| 247 | +## 対象外(今回のスコープ外) |
| 248 | + |
| 249 | +- ファイル間の余白改善(セパレータ幅の動的化など) |
| 250 | +- コメントの視覚的階層改善 |
| 251 | +- フッター情報量の削減(`design-review-keybindings.md` P2 として別途対応) |
| 252 | +- 表示行数のターミナル高さ連動 |
| 253 | +- 要件定義書(`docs/requirements.md`)のキーバインド表への `]c`/`[c` 追記は実装完了後に `/update-docs` で対応 |
| 254 | + |
| 255 | +--- |
| 256 | + |
| 257 | +## update-plan 検証結果 |
| 258 | + |
| 259 | +### 設計書品質評価 |
| 260 | + |
| 261 | +| # | カテゴリ | 点数 | コメント | |
| 262 | +|---|---------|------|---------| |
| 263 | +| 1 | 基本構造 | 9/10 | 概要・コンポーネント設計・実装詳細・テスト・検証方法・ファイル構成が揃っている | |
| 264 | +| 2 | 目的・スコープの明確性 | 9/10 | 何を実装し何を実装しないかが明確。対象外も列挙済み | |
| 265 | +| 3 | コンポーネント設計の完全性 | 9/10 | DiffLine.tsx の全case、pendingBracket の型定義、state遷移図、データフローを記載 | |
| 266 | +| 4 | AWS SDK連携設計 | N/A | 本変更にAWS SDK連携なし | |
| 267 | +| 5 | 内部アーキテクチャ | 9/10 | ファイル構成表、データフロー図、state遷移図、`useInput` 内の配置順序を記載 | |
| 268 | +| 6 | エッジケース・異常系 | 9/10 | 8ケースの網羅的な表、行番号オーバーフロー、モーダル中の挙動を記載 | |
| 269 | +| 7 | テスト戦略 | 9/10 | TDDサイクル(Red→Green→Refactor)で構成。具体的なテストケース、既存テスト影響分析を記載 | |
| 270 | +| 8 | セキュリティ考慮 | N/A | 本変更にセキュリティ影響なし | |
| 271 | +| 9 | 技術選定の根拠 | 9/10 | stateベース vs タイムアウトの比較、`design-review-keybindings.md` との整合性説明、ユーザー選択の経緯 | |
| 272 | +| 10 | 実装計画 | 9/10 | 2イテレーション、Tidy First判定、TDDサイクル、対象ファイル/行番号が明確 | |
| 273 | +| | **合計** | **72/80 (90%)** | N/A 2項目を除外した8項目での評価 | |
| 274 | + |
| 275 | +**総合判定**: 🟢 実装着手可能(90%) |
| 276 | + |
| 277 | +### 整合性チェック |
| 278 | + |
| 279 | +| チェック項目 | 結果 | 詳細 | |
| 280 | +|-------------|------|------| |
| 281 | +| 設計書 ↔ ソースコード | ✅ | `DisplayLine` に `beforeLineNumber`/`afterLineNumber` が既存(`formatDiff.ts:1-24`)。`DiffLine.tsx` の switch-case 構造と一致 | |
| 282 | +| ロードマップ ↔ 設計書 ↔ 要件定義 | ✅ | v0.3.0 UX強化(diff可読性・ナビゲーション向上)に合致 | |
| 283 | +| CLAUDE.md との整合性 | ✅ | TDDサイクル、Tidy First判定、カバレッジ95%を担保 | |
| 284 | +| ソースコード規約 | ✅ | 既存のキーハンドラパターン、`Help.tsx` の `SectionHeader`+`KeyRow` パターンに従う | |
| 285 | +| design-review-keybindings.md | ✅ | 2ストローク操作にタイムアウト不要の根拠を記載 | |
| 286 | + |
| 287 | +### 修正事項 |
| 288 | + |
| 289 | +- **P0**: なし |
| 290 | +- **P1**: なし |
| 291 | +- **P2**: フッターがさらに長くなる(`]c/[c change` 追加)が、`design-review-keybindings.md` P2(フッター整理)で別途対応 |
0 commit comments