Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 10 additions & 15 deletions .claude/plans/plan.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,15 @@
# Plan: プレビュー機能の追加
# Plan: キーバインディングヘルパーの追加 (issue #9)

## 要件
- フォーカス中ペインの下に、カーソル行ファイルのプレビューを常時表示する。
- Source フォーカス時は Source ペインの下、Dest フォーカス時は Dest ペインの下。
- テキストファイルは内容、ディレクトリは中の一覧を表示。バイナリ/読み込み失敗は不可メッセージ。
- `?` でキーバインド一覧(ヘルプ)を中央モーダルで表示する。
- ヘルプ表示中は任意キー/Esc/q で閉じる。
- 既存の `?`(後方検索)は廃止する。/ の前方検索と n/N の双方向巡回は維持。

## タスク
- [x] 1. ブランチ作成(feat/preview-pane)
- [x] 2. FileSystem に ReadFile を追加(osfs 実装 + テスト用モック更新)
- [x] 3. 失敗するテストを書く(previewLines / isBinary / render 統合)
- [x] 4. previewLines・isBinary・renderPreview を実装しテストをパス
- [x] 5. render() にフォーカス側の縦分割を統合
- [x] 1. ブランチ作成(feat/keybinding-help)
- [x] 2. 失敗するテストを書く(? でヘルプ表示 / 任意キーで閉じる / render にヘルプ内容)
- [x] 3. 後方検索の削除(InputModeSearchBackward, searchBackward, inputPrefix/isTextInputMode)
- [x] 4. ヘルプ機能の実装(help.go: helpEntries, renderHelpModal, showHelp 状態, ルーティング)
- [x] 5. ステータスバーに [?]Help を追記 + README 更新
- [x] 6. gofmt → golangci-lint → go test ./... → nix build
- [x] 7. PR 作成(#16)

## 追加対応(レビュー反映)
- [x] 8. プレビュー高さを 50% に変更
- [x] 9. プレビューをフォーカス可能にしスクロール対応(Tab巡回 + j/k + gg/G)
- [x] 10. gofmt → golangci-lint → go test ./... → nix build
- [ ] 7. PR 作成
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ Keys available in the popup:
| Key | Action |
|-----|--------|
| `.` | Toggle hidden files visibility |
| `?` | Show keybindings help (press any key / `Esc` to close) |
| `q` | Quit |

## Basic Usage
Expand Down
1 change: 1 addition & 0 deletions README_ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ Nerd Fonts をインストールしたターミナルで使用すると、ファ
| キー | 操作 |
|------|------|
| `.` | 隠しファイルの表示/非表示を切り替え |
| `?` | キーバインド一覧(ヘルプ)を表示(任意キー / `Esc` で閉じる) |
| `q` | 終了 |

## 基本的な使い方
Expand Down
70 changes: 70 additions & 0 deletions internal/ui/help.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package ui

import (
"strings"

"github.com/charmbracelet/lipgloss"
"github.com/mattn/go-runewidth"
)

// helpEntry は1つのキーバインドの表記と説明を表す。
type helpEntry struct {
keys string // キー表記
desc string // 説明
}

// helpEntries はヘルプモーダルに表示するキーバインド一覧を返す。
func helpEntries() []helpEntry {
return []helpEntry{
{"j / k", "Move cursor down / up"},
{"h / l", "Go to parent / enter directory"},
{"gg / G", "Jump to top / bottom"},
{"Tab", "Switch left / right pane"},
{"Shift+Tab", "Switch pane / preview"},
{"Space", "Toggle selection"},
{"Ctrl+a", "Select all"},
{"Ctrl+r", "Invert selection"},
{"y", "Yank (mark copy source)"},
{"p / P", "Paste / overwrite paste"},
{"d / D", "Move to trash / delete permanently"},
{"a", "Create new (trailing / for directory)"},
{"r", "Rename"},
{"/", "Search"},
{"n / N", "Next / previous search result"},
{"f", "Filter"},
{".", "Toggle hidden files"},
{"s", "Select repository"},
{"?", "Show this help"},
{"Esc", "Clear selection / filter"},
{"q", "Quit"},
}
}

// renderHelpModal はキーバインド一覧の中央モーダルを描画する。
func (a *App) renderHelpModal() string {
entries := helpEntries()

// キー表記カラムの最大幅を求めて説明をそろえる。
keyWidth := 0
for _, e := range entries {
if w := runewidth.StringWidth(e.keys); w > keyWidth {
keyWidth = w
}
}

title := lipgloss.NewStyle().Bold(true).Render(" Keybindings ")
lines := make([]string, 0, len(entries)+1)
lines = append(lines, title, "")
for _, e := range entries {
pad := strings.Repeat(" ", keyWidth-runewidth.StringWidth(e.keys))
lines = append(lines, " "+e.keys+pad+" "+e.desc)
}
lines = append(lines, "", " any key / Esc to close ")

content := strings.Join(lines, "\n")
boxStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colorBorderActive).
Padding(0, 1)
return boxStyle.Render(content)
}
16 changes: 8 additions & 8 deletions internal/ui/keybinding.go
Original file line number Diff line number Diff line change
Expand Up @@ -441,11 +441,11 @@ func (a *App) searchForward() tea.Cmd {
return a.enterTextInput(InputModeSearch, "")
}

// searchBackward は後方検索モードに入る。
func (a *App) searchBackward() tea.Cmd {
// toggleHelp はキーバインドヘルプの表示/非表示を切り替える。
func (a *App) toggleHelp() {
a.resetGPending()
a.searchFwd = false
return a.enterTextInput(InputModeSearchBackward, "")
a.showHelp = !a.showHelp
a.Status = ""
}

// searchNext は次の検索結果に移動する。
Expand Down Expand Up @@ -509,10 +509,10 @@ func (a *App) inputSubmit() {
a.input.SetValue("")

switch mode {
case InputModeSearch, InputModeSearchBackward:
case InputModeSearch:
a.searchQuery = input
// 確定時に検索方向を保存し、後続のn/Nが正しい方向を使えるようにする
a.searchFwd = mode == InputModeSearch
// 確定時に検索方向を前方として保存し、後続のn/Nが正しい方向を使えるようにする
a.searchFwd = true
if input == "" {
a.Status = ""
return
Expand All @@ -521,7 +521,7 @@ func (a *App) inputSubmit() {
if p == nil {
return
}
a.doSearch(p, mode == InputModeSearch)
a.doSearch(p, true)
case InputModeFilter:
p := a.focusedPane()
if p == nil {
Expand Down
8 changes: 3 additions & 5 deletions internal/ui/layout.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,6 @@ const (
InputModeNone InputMode = iota
// InputModeSearch は検索モード(/)。
InputModeSearch
// InputModeSearchBackward は後方検索モード(?)。
InputModeSearchBackward
// InputModeFilter はフィルタモード(f)。
InputModeFilter
// InputModeCreate は新規作成モード(a)。
Expand Down Expand Up @@ -64,6 +62,8 @@ type App struct {
input textinput.Model // 入力欄(検索/フィルタ/作成/リネーム)
pendingTargets []string // 削除確認時のターゲットパスのスナップショット

showHelp bool // キーバインドヘルプを表示中か

previewFocused bool // プレビュー領域にフォーカスしているか
previewScroll int // プレビューのスクロールオフセット(行)
previewKey string // 現在プレビュー中の対象を識別するキー(変化でスクロールリセット)
Expand Down Expand Up @@ -120,8 +120,6 @@ func (a *App) inputPrefix() string {
switch a.inputMode {
case InputModeSearch:
return "/:"
case InputModeSearchBackward:
return "?:"
case InputModeFilter:
return "filter: "
case InputModeCreate:
Expand All @@ -135,7 +133,7 @@ func (a *App) inputPrefix() string {
// isTextInputMode はテキスト入力を受け付けるモードかを返す。
func (a *App) isTextInputMode() bool {
switch a.inputMode {
case InputModeSearch, InputModeSearchBackward, InputModeFilter, InputModeCreate, InputModeRename:
case InputModeSearch, InputModeFilter, InputModeCreate, InputModeRename:
return true
}
return false
Expand Down
12 changes: 11 additions & 1 deletion internal/ui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ func (a *App) forwardToInput(msg tea.Msg) (tea.Model, tea.Cmd) {
// handleKey はキー入力を現在のモードに応じてルーティングする。
func (a *App) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case a.showHelp:
return a.handleHelpKey(msg)
case a.inputMode == InputModeSelectRepo:
return a.handleSelectorKey(msg)
case a.isTextInputMode():
Expand Down Expand Up @@ -104,7 +106,7 @@ func (a *App) handleNormalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "/":
return a, a.searchForward()
case "?":
return a, a.searchBackward()
a.toggleHelp()
case "n":
a.searchNext()
case "N":
Expand Down Expand Up @@ -145,6 +147,14 @@ func (a *App) handleConfirmKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return a, nil
}

// handleHelpKey はヘルプ表示中のキー入力を処理する。任意キーでヘルプを閉じる。
func (a *App) handleHelpKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
a.resetGPending()
a.showHelp = false
a.Status = ""
return a, nil
}

// View はtea.Modelインターフェースの実装。画面全体を文字列として返す。
func (a *App) View() string {
return a.render()
Expand Down
50 changes: 35 additions & 15 deletions internal/ui/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,30 +212,50 @@ func TestUpdate_削除確認_nでキャンセル(t *testing.T) {
}
}

func TestUpdate_後方検索_クエスチョンで方向を保存(t *testing.T) {
func TestUpdate_ヘルプ_クエスチョンで表示(t *testing.T) {
app := newDestApp()
app.DestPane.Cursor = 1 // banana

// '?' で後方検索モードに入る
// '?' でヘルプを表示する
app.Update(keyRunes("?"))
if app.inputMode != InputModeSearchBackward {
t.Fatalf("inputMode = %d, want InputModeSearchBackward", app.inputMode)
if !app.showHelp {
t.Fatal("? 押下後はshowHelpがtrueであるべき")
}
}

// "apple" を入力して確定 → 後方検索でapple(idx0)にジャンプ
for _, ch := range []string{"a", "p", "p", "l", "e"} {
app.Update(keyRunes(ch))
func TestUpdate_ヘルプ_任意キーで閉じる(t *testing.T) {
app := newDestApp()
app.showHelp = true

// ヘルプ表示中に任意キーで閉じる
app.Update(keyRunes("j"))
if app.showHelp {
t.Error("任意キー押下後はshowHelpがfalseであるべき")
}
app.Update(tea.KeyMsg{Type: tea.KeyEnter})
}

func TestUpdate_ヘルプ表示中はカーソルが動かない(t *testing.T) {
app := newDestApp()
app.DestPane.Cursor = 0
app.showHelp = true

if app.searchFwd {
t.Error("後方検索確定後はsearchFwdがfalseであるべき")
// ヘルプを閉じるだけでカーソルは移動しない
app.Update(keyRunes("j"))
if app.DestPane.Cursor != 0 {
t.Errorf("Cursor = %d, want 0 (ヘルプを閉じるだけで移動しない)", app.DestPane.Cursor)
}
if app.searchQuery != "apple" {
t.Errorf("searchQuery = %q, want \"apple\"", app.searchQuery)
}

func TestRender_ヘルプ表示時はキー一覧を含む(t *testing.T) {
app := newDestApp()
app.width, app.height = 80, 24
app.showHelp = true

out := app.render()
if !strings.Contains(out, "Keybindings") {
t.Errorf("ヘルプにタイトル Keybindings が含まれない:\n%s", out)
}
if app.DestPane.Cursor != 0 {
t.Errorf("Cursor = %d, want 0 (apple)", app.DestPane.Cursor)
if !strings.Contains(out, "Quit") {
t.Errorf("ヘルプに q の説明が含まれない:\n%s", out)
}
}

Expand Down
7 changes: 6 additions & 1 deletion internal/ui/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ func (a *App) render() string {
return ""
}

// ヘルプ表示中は中央モーダルで全画面を占有する
if a.showHelp {
return lipgloss.Place(a.width, a.height, lipgloss.Center, lipgloss.Center, a.renderHelpModal())
}

// リポジトリ選択ポップアップモードは中央モーダルで全画面を占有する
if a.inputMode == InputModeSelectRepo {
return lipgloss.Place(a.width, a.height, lipgloss.Center, lipgloss.Center, a.renderSelectorModal())
Expand Down Expand Up @@ -262,7 +267,7 @@ func (a *App) renderStatus() string {
if a.previewFocused {
return fmt.Sprintf(" [%s preview] [q]Quit [Tab]Pane [S-Tab]Pane/Preview [j/k]Scroll", focus)
}
return fmt.Sprintf(" [%s] [q]Quit [Tab]Pane [S-Tab]Preview [h/j/k/l]Move", focus)
return fmt.Sprintf(" [%s] [q]Quit [Tab]Pane [S-Tab]Preview [h/j/k/l]Move [?]Help", focus)
}

// renderSelectorModal はリポジトリ選択ポップアップのモーダルを描画する。
Expand Down
Loading