diff --git a/.claude/plans/plan.md b/.claude/plans/plan.md index 2a6d77d..8808ca3 100644 --- a/.claude/plans/plan.md +++ b/.claude/plans/plan.md @@ -1,13 +1,20 @@ -# Plan: UI を gocui から Bubble Tea へ移行 +# Plan: プレビュー機能の追加 + +## 要件 +- フォーカス中ペインの下に、カーソル行ファイルのプレビューを常時表示する。 + - Source フォーカス時は Source ペインの下、Dest フォーカス時は Dest ペインの下。 +- テキストファイルは内容、ディレクトリは中の一覧を表示。バイナリ/読み込み失敗は不可メッセージ。 ## タスク -- [x] 1. ブランチ作成(feat/migrate-bubbletea) -- [x] 2. go get(bubbletea / lipgloss / bubbles) -- [x] 3. gomarkdoc で docs/lib/{bubbletea,lipgloss,textinput}.md 生成 -- [x] 4. App を tea.Model 化(Init/Update/View)— 通常モードのペイン描画・移動 -- [x] 5. 入力モード(textinput)・確認モード・検索/フィルタ・作成/リネーム移植 -- [x] 6. セレクタポップアップ移植(中央モーダル) -- [x] 7. gocui 依存コード削除(SetGui/Layout/render*/keybinding登録/inputEditor/selector View群) -- [x] 8. main.go 置換、flake.nix vendorHash 更新、docs/lib/gocui.md 削除、design.md/CLAUDE.md 更新 -- [x] 9. gofmt → golangci-lint → go test ./... → nix build -- [ ] 10. PR 作成 +- [x] 1. ブランチ作成(feat/preview-pane) +- [x] 2. FileSystem に ReadFile を追加(osfs 実装 + テスト用モック更新) +- [x] 3. 失敗するテストを書く(previewLines / isBinary / render 統合) +- [x] 4. previewLines・isBinary・renderPreview を実装しテストをパス +- [x] 5. render() にフォーカス側の縦分割を統合 +- [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 diff --git a/internal/fs/fs.go b/internal/fs/fs.go index f17f607..3dfff52 100644 --- a/internal/fs/fs.go +++ b/internal/fs/fs.go @@ -14,6 +14,9 @@ type FileSystem interface { // ReadDir はディレクトリのエントリ一覧を返す。 // ディレクトリを先、ファイルを後にソートし、それぞれアルファベット順。 ReadDir(path string) ([]Entry, error) + // ReadFile はファイルの内容を返す。 + // プレビュー用途のため、実装は読み込むサイズを上限付きで制限してよい。 + ReadFile(path string) ([]byte, error) // Copy はsrcをdstにコピーする。ディレクトリの場合は再帰的にコピーする。 Copy(src, dst string) error // Remove はファイル/ディレクトリを完全削除する。 diff --git a/internal/fs/osfs.go b/internal/fs/osfs.go index 4b59a81..5d315f7 100644 --- a/internal/fs/osfs.go +++ b/internal/fs/osfs.go @@ -12,6 +12,22 @@ import ( // OSFS はFileSystemインターフェースのOS標準実装。 type OSFS struct{} +// previewReadLimit はReadFileが一度に読み込む最大バイト数。 +// 巨大ファイルのプレビューでメモリを消費しすぎないよう上限を設ける。 +const previewReadLimit = 64 * 1024 + +// ReadFile はファイルの内容を返す。 +// プレビュー用途のため、先頭から最大previewReadLimitバイトまでを読み込む。 +func (o *OSFS) ReadFile(path string) ([]byte, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer func() { _ = f.Close() }() + + return io.ReadAll(io.LimitReader(f, previewReadLimit)) +} + // ReadDir はディレクトリのエントリ一覧を返す。 // ディレクトリを先、ファイルを後にソートし、それぞれアルファベット順。 func (o *OSFS) ReadDir(path string) ([]Entry, error) { diff --git a/internal/pane/pane_test.go b/internal/pane/pane_test.go index 724e356..dff65fa 100644 --- a/internal/pane/pane_test.go +++ b/internal/pane/pane_test.go @@ -21,6 +21,7 @@ func (m *mockFS) ReadDir(path string) ([]fs.Entry, error) { return entries, nil } +func (m *mockFS) ReadFile(path string) ([]byte, error) { return nil, nil } func (m *mockFS) Copy(src, dst string) error { return nil } func (m *mockFS) Remove(path string) error { return nil } func (m *mockFS) Trash(path string) error { return nil } diff --git a/internal/ui/keybinding.go b/internal/ui/keybinding.go index 5dfa875..898678a 100644 --- a/internal/ui/keybinding.go +++ b/internal/ui/keybinding.go @@ -77,13 +77,63 @@ func (a *App) parentDir() { } } -// toggleFocus はペイン間のフォーカスを切り替える。 +// toggleFocus はペイン間(左右)のフォーカスを切り替える(横移動)。 +// 縦位置(ペイン/プレビュー)は維持する。SourcePaneが未選択の場合は移動しない。 func (a *App) toggleFocus() { a.resetGPending() - a.FocusLeft = !a.FocusLeft + if a.SourcePane != nil { + a.FocusLeft = !a.FocusLeft + } + a.Status = "" +} + +// toggleFocusVertical はペインとプレビュー間(上下)のフォーカスを切り替える(縦移動)。 +// 左右位置は維持する。 +func (a *App) toggleFocusVertical() { + a.resetGPending() + a.previewFocused = !a.previewFocused a.Status = "" } +// handlePreviewKey はプレビューフォーカス時のキー入力を処理する。 +func (a *App) handlePreviewKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "tab": + a.toggleFocus() + case "shift+tab": + a.toggleFocusVertical() + case "j", "down": + a.resetGPending() + a.previewScrollDown() + case "k", "up": + a.resetGPending() + a.previewScrollUp() + case "g": + a.handlePreviewG() + case "G": + a.resetGPending() + a.previewScroll = a.previewMaxScroll() + case "esc": + a.resetGPending() + a.previewFocused = false + a.previewScroll = 0 + a.Status = "" + case "q", "ctrl+c": + return a, a.quit() + } + return a, nil +} + +// handlePreviewG はプレビューフォーカス時の'g'キーを処理する。ggで先頭へ。 +func (a *App) handlePreviewG() { + if a.gPending { + a.gPending = false + a.previewScroll = 0 + return + } + a.gPending = true +} + // selectRepo はリポジトリ選択ポップアップを開く。 func (a *App) selectRepo() tea.Cmd { a.resetGPending() diff --git a/internal/ui/layout.go b/internal/ui/layout.go index 44c8250..56fbd9a 100644 --- a/internal/ui/layout.go +++ b/internal/ui/layout.go @@ -64,6 +64,12 @@ type App struct { input textinput.Model // 入力欄(検索/フィルタ/作成/リネーム) pendingTargets []string // 削除確認時のターゲットパスのスナップショット + previewFocused bool // プレビュー領域にフォーカスしているか + previewScroll int // プレビューのスクロールオフセット(行) + previewKey string // 現在プレビュー中の対象を識別するキー(変化でスクロールリセット) + previewTotalLines int // 直近の描画で算出したプレビュー総行数 + previewVisibleLines int // 直近の描画で算出したプレビュー可視行数 + repoList []string // リポジトリ一覧(全件) filteredRepoList []string // フィルタ済みリポジトリ一覧 repoSelectorCursor int // リポジトリ選択カーソル位置 diff --git a/internal/ui/model.go b/internal/ui/model.go index e5336fd..5eb60a8 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -49,6 +49,8 @@ func (a *App) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return a.handleInputKey(msg) case a.inputMode == InputModeConfirmDelete || a.inputMode == InputModeConfirmForceDelete: return a.handleConfirmKey(msg) + case a.previewFocused: + return a.handlePreviewKey(msg) default: return a.handleNormalKey(msg) } @@ -67,6 +69,8 @@ func (a *App) handleNormalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { a.parentDir() case "tab": a.toggleFocus() + case "shift+tab": + a.toggleFocusVertical() case "s": return a, a.selectRepo() case "q": diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index b087d13..e0354cb 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -23,10 +23,17 @@ type stubFS struct { entries []fs.Entry trashed []string removed []string - copies [][2]string // [src, dst] の記録 + copies [][2]string // [src, dst] の記録 + files map[string][]byte // パスごとのファイル内容(プレビュー用) } func (s *stubFS) ReadDir(string) ([]fs.Entry, error) { return s.entries, nil } +func (s *stubFS) ReadFile(p string) ([]byte, error) { + if data, ok := s.files[p]; ok { + return data, nil + } + return nil, errStub +} func (s *stubFS) Copy(src, dst string) error { s.copies = append(s.copies, [2]string{src, dst}) return nil @@ -101,14 +108,26 @@ func TestUpdate_スペースでマークのみトグルしカーソルは移動 } } -func TestUpdate_Tabでフォーカス切替(t *testing.T) { +func TestUpdate_Tabで左右ペインを切り替え(t *testing.T) { app := newDestApp() + app.SourcePane = &pane.Pane{ + Dir: "/src", + Entries: []fs.Entry{{Name: "x"}}, + Cursor: 0, + Selected: map[int]bool{}, + } if app.FocusLeft { - t.Fatal("初期状態は右フォーカスのはず") + t.Fatal("初期状態は右(Dest)フォーカスのはず") } + // Destペイン → Sourceペイン(横移動) app.Update(tea.KeyMsg{Type: tea.KeyTab}) if !app.FocusLeft { - t.Error("Tab後は左フォーカスになるべき") + t.Errorf("Tabで左ペインになるべき: FocusLeft=%v", app.FocusLeft) + } + // Sourceペイン → Destペイン + app.Update(tea.KeyMsg{Type: tea.KeyTab}) + if app.FocusLeft { + t.Errorf("Tabで右ペインに戻るべき: FocusLeft=%v", app.FocusLeft) } } diff --git a/internal/ui/preview.go b/internal/ui/preview.go new file mode 100644 index 0000000..f3c6033 --- /dev/null +++ b/internal/ui/preview.go @@ -0,0 +1,161 @@ +package ui + +import ( + "bytes" + "path/filepath" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/myuron/graftx/internal/pane" +) + +// isBinary はデータがバイナリかどうかをNULバイトの有無で簡易判定する。 +func isBinary(data []byte) bool { + return bytes.IndexByte(data, 0) >= 0 +} + +// previewContent はペインのカーソル行に対応するプレビュー内容を全行返す。 +// テキストファイルは内容、ディレクトリは中の一覧、バイナリや読み込み失敗は +// その旨のメッセージを返す。各行は幅widthに切り詰める。 +func (a *App) previewContent(p *pane.Pane, width int) []string { + if p == nil || len(p.Entries) == 0 { + return []string{"(プレビューなし)"} + } + + entry := p.Entries[p.Cursor] + path := filepath.Join(p.Dir, entry.Name) + + if entry.IsDir { + entries, err := a.FS.ReadDir(path) + if err != nil { + return []string{"(読み込みエラー)"} + } + if len(entries) == 0 { + return []string{"(空のディレクトリ)"} + } + lines := make([]string, 0, len(entries)) + for _, e := range entries { + name := e.Name + if e.IsDir { + name += "/" + } + lines = append(lines, truncateToWidth(name, width)) + } + return lines + } + + data, err := a.FS.ReadFile(path) + if err != nil { + return []string{"(読み込みエラー)"} + } + if isBinary(data) { + return []string{"(バイナリファイル)"} + } + if len(data) == 0 { + return []string{"(空のファイル)"} + } + + raw := strings.Split(string(data), "\n") + lines := make([]string, 0, len(raw)) + for _, line := range raw { + // CRLF対応とタブ展開で表示崩れを防ぐ + line = strings.TrimSuffix(line, "\r") + line = strings.ReplaceAll(line, "\t", " ") + lines = append(lines, truncateToWidth(line, width)) + } + return lines +} + +// previewMaxScroll は直近の描画情報から最大スクロールオフセットを返す。 +func (a *App) previewMaxScroll() int { + max := a.previewTotalLines - a.previewVisibleLines + if max < 0 { + return 0 + } + return max +} + +// previewScrollDown はプレビューを1行下にスクロールする。 +func (a *App) previewScrollDown() { + if a.previewScroll < a.previewMaxScroll() { + a.previewScroll++ + } +} + +// previewScrollUp はプレビューを1行上にスクロールする。 +func (a *App) previewScrollUp() { + if a.previewScroll > 0 { + a.previewScroll-- + } +} + +// renderPreview はフォーカス中ペインのプレビュー領域を枠付きで描画する。 +// focusedがtrueの場合は枠をアクティブ色にする。 +func (a *App) renderPreview(p *pane.Pane, outerW, outerH int, focused bool) string { + innerW := outerW - 2 + innerH := outerH - 2 + if innerW < 1 { + innerW = 1 + } + if innerH < 1 { + innerH = 1 + } + + borderColor := colorBorderIdle + if focused { + borderColor = colorBorderActive + } + boxStyle := lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(borderColor). + Width(innerW). + Height(innerH) + + // タイトル1行ぶんを差し引いた高さに本文を収める + bodyHeight := innerH - 1 + if bodyHeight < 1 { + bodyHeight = 1 + } + + content := a.previewContent(p, innerW) + + // スクロール状態を直近の描画情報として保存し、範囲外をクランプする + a.previewTotalLines = len(content) + a.previewVisibleLines = bodyHeight + if a.previewScroll > a.previewMaxScroll() { + a.previewScroll = a.previewMaxScroll() + } + if a.previewScroll < 0 { + a.previewScroll = 0 + } + + // 可視範囲を切り出す + start := a.previewScroll + end := start + bodyHeight + if end > len(content) { + end = len(content) + } + visible := content[start:end] + + normalStyle := lipgloss.NewStyle().Width(innerW) + rendered := make([]string, 0, bodyHeight) + for _, line := range visible { + rendered = append(rendered, normalStyle.Render(line)) + } + for len(rendered) < bodyHeight { + rendered = append(rendered, normalStyle.Render("")) + } + + titleLine := lipgloss.NewStyle().Bold(true).MaxWidth(innerW).Render(a.previewTitle()) + content2 := titleLine + "\n" + strings.Join(rendered, "\n") + return boxStyle.Render(content2) +} + +// previewTitle はプレビュー領域のタイトル文字列を返す。 +// スクロール可能な場合は位置インジケータを付与する。 +func (a *App) previewTitle() string { + if a.previewMaxScroll() > 0 { + return " Preview ▲▼ " + } + return " Preview " +} diff --git a/internal/ui/preview_test.go b/internal/ui/preview_test.go new file mode 100644 index 0000000..03d82ff --- /dev/null +++ b/internal/ui/preview_test.go @@ -0,0 +1,306 @@ +package ui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/myuron/graftx/internal/fs" + "github.com/myuron/graftx/internal/pane" +) + +func TestIsBinary_NULバイトを含むかで判定(t *testing.T) { + tests := []struct { + name string + data []byte + want bool + }{ + {name: "テキスト", data: []byte("hello world"), want: false}, + {name: "空", data: []byte(""), want: false}, + {name: "NULバイトを含む", data: []byte{'a', 0x00, 'b'}, want: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isBinary(tt.data); got != tt.want { + t.Errorf("isBinary(%v) = %v, want %v", tt.data, got, tt.want) + } + }) + } +} + +func TestPreviewLines_テキストファイルの内容を行で返す(t *testing.T) { + fsys := &stubFS{files: map[string][]byte{ + "/repo/a.txt": []byte("hello\nworld\n"), + }} + app := &App{FS: fsys} + p := &pane.Pane{ + Dir: "/repo", + Entries: []fs.Entry{{Name: "a.txt"}}, + Cursor: 0, + Selected: map[int]bool{}, + } + + lines := app.previewContent(p, 20) + joined := strings.Join(lines, "\n") + if !strings.Contains(joined, "hello") || !strings.Contains(joined, "world") { + t.Errorf("テキスト内容が含まれていない: %q", joined) + } +} + +func TestPreviewLines_バイナリは不可メッセージ(t *testing.T) { + fsys := &stubFS{files: map[string][]byte{ + "/repo/bin": {0x00, 0x01, 0x02}, + }} + app := &App{FS: fsys} + p := &pane.Pane{ + Dir: "/repo", + Entries: []fs.Entry{{Name: "bin"}}, + Cursor: 0, + Selected: map[int]bool{}, + } + + joined := strings.Join(app.previewContent(p, 20), "\n") + if !strings.Contains(joined, "バイナリ") { + t.Errorf("バイナリ不可メッセージが含まれていない: %q", joined) + } +} + +func TestPreviewLines_ディレクトリは中の一覧を返す(t *testing.T) { + fsys := &stubFS{entries: []fs.Entry{ + {Name: "sub", IsDir: true}, + {Name: "f.go"}, + }} + app := &App{FS: fsys} + p := &pane.Pane{ + Dir: "/repo", + Entries: []fs.Entry{{Name: "dir", IsDir: true}}, + Cursor: 0, + Selected: map[int]bool{}, + } + + joined := strings.Join(app.previewContent(p, 20), "\n") + if !strings.Contains(joined, "sub/") || !strings.Contains(joined, "f.go") { + t.Errorf("ディレクトリ一覧が含まれていない: %q", joined) + } +} + +func TestPreviewLines_読み込み失敗は不可メッセージ(t *testing.T) { + app := &App{FS: &stubFS{}} + p := &pane.Pane{ + Dir: "/repo", + Entries: []fs.Entry{{Name: "missing"}}, + Cursor: 0, + Selected: map[int]bool{}, + } + + joined := strings.Join(app.previewContent(p, 20), "\n") + if joined == "" { + t.Errorf("読み込み失敗時にメッセージが返るべき") + } +} + +func TestPreviewLines_空ペインでもパニックしない(t *testing.T) { + app := &App{FS: &stubFS{}} + p := &pane.Pane{Dir: "/repo", Entries: nil, Cursor: 0, Selected: map[int]bool{}} + if lines := app.previewContent(p, 20); len(lines) == 0 { + t.Errorf("空ペインでも1行以上返すべき") + } + if lines := app.previewContent(nil, 20); len(lines) == 0 { + t.Errorf("nilペインでも1行以上返すべき") + } +} + +func TestPreviewScroll_範囲内でクランプされる(t *testing.T) { + // 総行数10・可視4行 → 最大オフセットは6 + a := &App{previewTotalLines: 10, previewVisibleLines: 4} + + a.previewScrollDown() + if a.previewScroll != 1 { + t.Errorf("1回下スクロールでオフセット1のはず: %d", a.previewScroll) + } + for i := 0; i < 100; i++ { + a.previewScrollDown() + } + if a.previewScroll != 6 { + t.Errorf("最大オフセット6でクランプされるべき: %d", a.previewScroll) + } + for i := 0; i < 100; i++ { + a.previewScrollUp() + } + if a.previewScroll != 0 { + t.Errorf("上スクロールで0でクランプされるべき: %d", a.previewScroll) + } +} + +func TestPreviewScroll_可視範囲が総行数以上ならスクロールしない(t *testing.T) { + a := &App{previewTotalLines: 3, previewVisibleLines: 5} + a.previewScrollDown() + if a.previewScroll != 0 { + t.Errorf("内容が可視範囲に収まる場合はスクロールしない: %d", a.previewScroll) + } +} + +func TestToggleFocus_横方向に左右ペインを切り替える(t *testing.T) { + a := &App{ + SourcePane: &pane.Pane{Selected: map[int]bool{}}, + DestPane: &pane.Pane{Selected: map[int]bool{}}, + FocusLeft: false, + } + + a.toggleFocus() // Dest → Source + if !a.FocusLeft { + t.Fatalf("横移動で左ペインになるべき: FocusLeft=%v", a.FocusLeft) + } + a.toggleFocus() // Source → Dest + if a.FocusLeft { + t.Fatalf("横移動で右ペインに戻るべき: FocusLeft=%v", a.FocusLeft) + } + + // 縦位置(プレビュー)は横移動で維持される + a.previewFocused = true + a.toggleFocus() + if !a.FocusLeft || !a.previewFocused { + t.Fatalf("横移動でプレビュー状態を維持すべき: FocusLeft=%v preview=%v", a.FocusLeft, a.previewFocused) + } +} + +func TestToggleFocus_Source未選択時は横移動しない(t *testing.T) { + a := &App{DestPane: &pane.Pane{Selected: map[int]bool{}}, FocusLeft: false} + + a.toggleFocus() + if a.FocusLeft { + t.Fatalf("Source未選択時は左に移動しないべき: FocusLeft=%v", a.FocusLeft) + } +} + +func TestToggleFocusVertical_縦方向にペインとプレビューを切り替える(t *testing.T) { + a := &App{DestPane: &pane.Pane{Selected: map[int]bool{}}, FocusLeft: false} + + a.toggleFocusVertical() // ペイン → プレビュー + if !a.previewFocused { + t.Fatalf("縦移動でプレビューにフォーカスすべき: preview=%v", a.previewFocused) + } + a.toggleFocusVertical() // プレビュー → ペイン + if a.previewFocused { + t.Fatalf("縦移動でペインに戻るべき: preview=%v", a.previewFocused) + } + + // 左右位置は縦移動で維持される + a.FocusLeft = true + a.toggleFocusVertical() + if !a.FocusLeft || !a.previewFocused { + t.Fatalf("縦移動で左右位置を維持すべき: FocusLeft=%v preview=%v", a.FocusLeft, a.previewFocused) + } +} + +func TestUpdate_ShiftTabで縦移動(t *testing.T) { + app := newDestApp() + + // Destペイン → Destプレビュー + app.Update(tea.KeyMsg{Type: tea.KeyShiftTab}) + if !app.previewFocused { + t.Errorf("Shift+Tabでプレビューにフォーカスするべき: preview=%v", app.previewFocused) + } + // Destプレビュー → Destペイン + app.Update(tea.KeyMsg{Type: tea.KeyShiftTab}) + if app.previewFocused { + t.Errorf("Shift+Tabでペインに戻るべき: preview=%v", app.previewFocused) + } +} + +func TestUpdate_プレビューフォーカス中のTabは横移動して維持(t *testing.T) { + app := newDestApp() + app.SourcePane = &pane.Pane{ + Dir: "/src", + Entries: []fs.Entry{{Name: "x"}}, + Cursor: 0, + Selected: map[int]bool{}, + } + + // Destプレビューにフォーカス + app.Update(tea.KeyMsg{Type: tea.KeyShiftTab}) + if app.FocusLeft || !app.previewFocused { + t.Fatalf("Destプレビューになるべき: FocusLeft=%v preview=%v", app.FocusLeft, app.previewFocused) + } + // Tab で横移動。プレビュー状態は維持されSourceプレビューへ + app.Update(tea.KeyMsg{Type: tea.KeyTab}) + if !app.FocusLeft || !app.previewFocused { + t.Errorf("Tabで横移動しプレビューを維持すべき: FocusLeft=%v preview=%v", app.FocusLeft, app.previewFocused) + } +} + +func TestUpdate_プレビューフォーカス時にjでスクロールする(t *testing.T) { + // 可視範囲を超える行数のファイルを用意する + var sb strings.Builder + for i := 0; i < 100; i++ { + sb.WriteString("line\n") + } + fsys := &stubFS{ + entries: []fs.Entry{{Name: "big.txt"}}, + files: map[string][]byte{"/dst/big.txt": []byte(sb.String())}, + } + app, _ := NewApp("/dst", fsys, nil) + app.DestPane = &pane.Pane{ + Dir: "/dst", + Entries: []fs.Entry{{Name: "big.txt"}}, + Cursor: 0, + Selected: map[int]bool{}, + } + app.width = 80 + app.height = 24 + + // プレビューにフォーカス(Destペイン → Destプレビュー) + app.Update(tea.KeyMsg{Type: tea.KeyShiftTab}) + if !app.previewFocused { + t.Fatal("Shift+Tabでプレビューにフォーカスするべき") + } + // 描画で総行数・可視行数を確定させる + app.View() + if app.previewScroll != 0 { + t.Fatalf("初期スクロールは0のはず: %d", app.previewScroll) + } + // j でスクロール + app.Update(keyRunes("j")) + if app.previewScroll != 1 { + t.Errorf("jでスクロールオフセットが1になるべき: %d", app.previewScroll) + } + // k で戻る + app.Update(keyRunes("k")) + if app.previewScroll != 0 { + t.Errorf("kでスクロールオフセットが0に戻るべき: %d", app.previewScroll) + } +} + +// render はフォーカス中ペインの下にプレビューを表示する。 +func TestRender_フォーカス側にプレビューが表示される(t *testing.T) { + fsys := &stubFS{ + entries: []fs.Entry{{Name: "apple"}}, + files: map[string][]byte{ + "/src/apple": []byte("SOURCE-CONTENT"), + }, + } + app, _ := NewApp("/dst", fsys, nil) + app.SourcePane = &pane.Pane{ + Dir: "/src", + Entries: []fs.Entry{{Name: "apple"}}, + Cursor: 0, + Selected: map[int]bool{}, + } + app.DestPane = &pane.Pane{ + Dir: "/dst", + Entries: []fs.Entry{{Name: "apple"}}, + Cursor: 0, + Selected: map[int]bool{}, + } + app.width = 80 + app.height = 24 + app.FocusLeft = true + + out := app.View() + if !strings.Contains(out, "Preview") { + t.Errorf("プレビュー領域のタイトルが表示されるべき: %q", out) + } + if !strings.Contains(out, "SOURCE-CONTENT") { + t.Errorf("フォーカス側のファイル内容が表示されるべき: %q", out) + } +} diff --git a/internal/ui/render.go b/internal/ui/render.go index bbc6e03..6201900 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -82,13 +82,69 @@ func (a *App) render() string { leftWidth := a.width / 2 rightWidth := a.width - leftWidth - left := a.renderPane(a.SourcePane, " Source ", a.FocusLeft, leftWidth, paneAreaHeight) - right := a.renderPane(a.DestPane, " Dest ", !a.FocusLeft, rightWidth, paneAreaHeight) + // プレビュー対象が変わったらスクロール位置をリセットする。 + a.syncPreviewScroll() + + // フォーカス側はペインとプレビューに縦分割し、非フォーカス側は全高で描画する。 + paneH, previewH := splitPreviewHeight(paneAreaHeight) + // ペインの枠とカーソルはプレビューフォーカス中でないときにアクティブ表示する。 + paneActive := !a.previewFocused + + var left, right string + if a.FocusLeft { + pane := a.renderPane(a.SourcePane, " Source ", paneActive, leftWidth, paneH) + preview := a.renderPreview(a.SourcePane, leftWidth, previewH, a.previewFocused) + left = lipgloss.JoinVertical(lipgloss.Left, pane, preview) + right = a.renderPane(a.DestPane, " Dest ", false, rightWidth, paneAreaHeight) + } else { + left = a.renderPane(a.SourcePane, " Source ", false, leftWidth, paneAreaHeight) + pane := a.renderPane(a.DestPane, " Dest ", paneActive, rightWidth, paneH) + preview := a.renderPreview(a.DestPane, rightWidth, previewH, a.previewFocused) + right = lipgloss.JoinVertical(lipgloss.Left, pane, preview) + } panes := lipgloss.JoinHorizontal(lipgloss.Top, left, right) return panes + "\n" + a.renderStatus() } +// splitPreviewHeight はフォーカス側の利用可能高さをペインとプレビューに配分する。 +// プレビューは全体の約50%を占め、双方が最低3行(枠込み)を確保できるよう調整する。 +func splitPreviewHeight(total int) (paneH, previewH int) { + paneH = total / 2 + previewH = total - paneH + if previewH < 3 { + previewH = 3 + paneH = total - previewH + } + if paneH < 3 { + paneH = 3 + previewH = total - paneH + if previewH < 1 { + previewH = 1 + } + } + return paneH, previewH +} + +// syncPreviewScroll はプレビュー対象(フォーカス中ペインのカーソル行)が +// 変化した場合にスクロール位置をリセットする。 +func (a *App) syncPreviewScroll() { + p := a.focusedPane() + key := "" + switch { + case p == nil: + key = "" + case len(p.Entries) == 0: + key = p.Dir + "\x00" + default: + key = p.Dir + "\x00" + p.Entries[p.Cursor].Name + } + if key != a.previewKey { + a.previewKey = key + a.previewScroll = 0 + } +} + // renderPane は1つのペインを枠付きで描画する。 // pがnilの場合(左ペイン未選択時)はプレースホルダを表示する。 func (a *App) renderPane(p *pane.Pane, defaultTitle string, focused bool, outerW, outerH int) string { @@ -203,7 +259,10 @@ func (a *App) renderStatus() string { if a.FocusLeft { focus = "left" } - return fmt.Sprintf(" [%s] [q]Quit [Tab]Switch [h/j/k/l]Move", focus) + 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) } // renderSelectorModal はリポジトリ選択ポップアップのモーダルを描画する。