From 1b303af45484c84afe308f1cec000201ebf93d64 Mon Sep 17 00:00:00 2001 From: myuron Date: Sun, 28 Jun 2026 15:51:33 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E3=83=95=E3=82=A9=E3=83=BC?= =?UTF-8?q?=E3=82=AB=E3=82=B9=E4=B8=AD=E3=83=9A=E3=82=A4=E3=83=B3=E3=81=AE?= =?UTF-8?q?=E4=B8=8B=E3=81=AB=E3=83=97=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC?= =?UTF-8?q?=E3=82=92=E5=B8=B8=E6=99=82=E8=A1=A8=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit カーソル行のファイル/ディレクトリの内容を、フォーカス中ペインの下に 常時表示するプレビュー領域を追加。Source フォーカス時は Source の下、 Dest フォーカス時は Dest の下に表示する。 - fs.FileSystem に ReadFile を追加(OSFS は先頭64KiBまで読み込み) - テキストは内容、ディレクトリは中の一覧を表示 - バイナリ・読み込み失敗・空はメッセージを表示 Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/plans/plan.md | 24 +++--- internal/fs/fs.go | 3 + internal/fs/osfs.go | 16 ++++ internal/pane/pane_test.go | 1 + internal/ui/model_test.go | 9 ++- internal/ui/preview.go | 117 +++++++++++++++++++++++++++++ internal/ui/preview_test.go | 144 ++++++++++++++++++++++++++++++++++++ internal/ui/render.go | 35 ++++++++- 8 files changed, 335 insertions(+), 14 deletions(-) create mode 100644 internal/ui/preview.go create mode 100644 internal/ui/preview_test.go diff --git a/.claude/plans/plan.md b/.claude/plans/plan.md index 2a6d77d..0745827 100644 --- a/.claude/plans/plan.md +++ b/.claude/plans/plan.md @@ -1,13 +1,15 @@ -# 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 +- [ ] 7. PR 作成 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/model_test.go b/internal/ui/model_test.go index b087d13..153aebf 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 diff --git a/internal/ui/preview.go b/internal/ui/preview.go new file mode 100644 index 0000000..c1a98ad --- /dev/null +++ b/internal/ui/preview.go @@ -0,0 +1,117 @@ +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 +} + +// previewLines はペインのカーソル行に対応するプレビュー内容を行配列で返す。 +// テキストファイルは内容、ディレクトリは中の一覧、バイナリや読み込み失敗は +// その旨のメッセージを返す。最大height行・幅widthに切り詰める。 +func (a *App) previewLines(p *pane.Pane, width, height int) []string { + if height < 1 { + height = 1 + } + 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{"(読み込みエラー)"} + } + lines := make([]string, 0, len(entries)) + for _, e := range entries { + name := e.Name + if e.IsDir { + name += "/" + } + lines = append(lines, truncateToWidth(name, width)) + if len(lines) >= height { + break + } + } + if len(lines) == 0 { + return []string{"(空のディレクトリ)"} + } + 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, height) + for _, line := range raw { + // CRLF対応とタブ展開で表示崩れを防ぐ + line = strings.TrimSuffix(line, "\r") + line = strings.ReplaceAll(line, "\t", " ") + lines = append(lines, truncateToWidth(line, width)) + if len(lines) >= height { + break + } + } + return lines +} + +// renderPreview はフォーカス中ペインのプレビュー領域を枠付きで描画する。 +func (a *App) renderPreview(p *pane.Pane, outerW, outerH int) string { + innerW := outerW - 2 + innerH := outerH - 2 + if innerW < 1 { + innerW = 1 + } + if innerH < 1 { + innerH = 1 + } + + boxStyle := lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(colorBorderIdle). + Width(innerW). + Height(innerH) + + // タイトル1行ぶんを差し引いた高さに本文を収める + bodyHeight := innerH - 1 + if bodyHeight < 1 { + bodyHeight = 1 + } + lines := a.previewLines(p, innerW, bodyHeight) + + normalStyle := lipgloss.NewStyle().Width(innerW) + rendered := make([]string, 0, bodyHeight) + for _, line := range lines { + rendered = append(rendered, normalStyle.Render(line)) + if len(rendered) >= bodyHeight { + break + } + } + for len(rendered) < bodyHeight { + rendered = append(rendered, normalStyle.Render("")) + } + + titleLine := lipgloss.NewStyle().Bold(true).MaxWidth(innerW).Render(" Preview ") + content := titleLine + "\n" + strings.Join(rendered, "\n") + return boxStyle.Render(content) +} diff --git a/internal/ui/preview_test.go b/internal/ui/preview_test.go new file mode 100644 index 0000000..8a59fb0 --- /dev/null +++ b/internal/ui/preview_test.go @@ -0,0 +1,144 @@ +package ui + +import ( + "strings" + "testing" + + "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.previewLines(p, 20, 5) + 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.previewLines(p, 20, 5), "\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.previewLines(p, 20, 5), "\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.previewLines(p, 20, 5), "\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.previewLines(p, 20, 5); len(lines) == 0 { + t.Errorf("空ペインでも1行以上返すべき") + } + if lines := app.previewLines(nil, 20, 5); len(lines) == 0 { + t.Errorf("nilペインでも1行以上返すべき") + } +} + +// 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..fd83086 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -82,13 +82,44 @@ 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) + // フォーカス側はペインとプレビューに縦分割し、非フォーカス側は全高で描画する。 + paneH, previewH := splitPreviewHeight(paneAreaHeight) + + var left, right string + if a.FocusLeft { + pane := a.renderPane(a.SourcePane, " Source ", true, leftWidth, paneH) + preview := a.renderPreview(a.SourcePane, leftWidth, previewH) + 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 ", true, rightWidth, paneH) + preview := a.renderPreview(a.DestPane, rightWidth, previewH) + right = lipgloss.JoinVertical(lipgloss.Left, pane, preview) + } panes := lipgloss.JoinHorizontal(lipgloss.Top, left, right) return panes + "\n" + a.renderStatus() } +// splitPreviewHeight はフォーカス側の利用可能高さをペインとプレビューに配分する。 +// プレビューは全体の約1/3を占め、双方が最低3行(枠込み)を確保できるよう調整する。 +func splitPreviewHeight(total int) (paneH, previewH int) { + previewH = total / 3 + if previewH < 3 { + previewH = 3 + } + paneH = total - previewH + if paneH < 3 { + paneH = 3 + previewH = total - paneH + if previewH < 1 { + previewH = 1 + } + } + return paneH, previewH +} + // renderPane は1つのペインを枠付きで描画する。 // pがnilの場合(左ペイン未選択時)はプレースホルダを表示する。 func (a *App) renderPane(p *pane.Pane, defaultTitle string, focused bool, outerW, outerH int) string { From 325f423ab4459c584aeef4391434d70fe4fc6057 Mon Sep 17 00:00:00 2001 From: myuron Date: Sun, 28 Jun 2026 16:00:33 +0000 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=E3=83=97=E3=83=AC=E3=83=93?= =?UTF-8?q?=E3=83=A5=E3=83=BC=E3=82=9250%=E9=AB=98=E3=81=95=E5=8C=96?= =?UTF-8?q?=E3=81=97=E3=83=95=E3=82=A9=E3=83=BC=E3=82=AB=E3=82=B9/?= =?UTF-8?q?=E3=82=B9=E3=82=AF=E3=83=AD=E3=83=BC=E3=83=AB=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - プレビュー領域の高さを約1/3から50%へ変更 - Tab の巡回にプレビューを追加(Sourceペイン→Sourceプレビュー→ Destペイン→Destプレビュー、Source未選択時はSourceをスキップ) - プレビューフォーカス時は j/k でスクロール、gg/G で先頭/末尾へ - プレビュー対象(カーソル行)が変わったらスクロール位置をリセット - フォーカス位置に応じて枠色とステータス表示を切り替え Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/plans/plan.md | 7 +- internal/ui/keybinding.go | 57 ++++++++++++++++- internal/ui/layout.go | 6 ++ internal/ui/model.go | 2 + internal/ui/model_test.go | 22 +++++-- internal/ui/preview.go | 96 ++++++++++++++++++++-------- internal/ui/preview_test.go | 124 ++++++++++++++++++++++++++++++++++-- internal/ui/render.go | 42 ++++++++++-- 8 files changed, 309 insertions(+), 47 deletions(-) diff --git a/.claude/plans/plan.md b/.claude/plans/plan.md index 0745827..8808ca3 100644 --- a/.claude/plans/plan.md +++ b/.claude/plans/plan.md @@ -12,4 +12,9 @@ - [x] 4. previewLines・isBinary・renderPreview を実装しテストをパス - [x] 5. render() にフォーカス側の縦分割を統合 - [x] 6. gofmt → golangci-lint → go test ./... → nix build -- [ ] 7. PR 作成 +- [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/ui/keybinding.go b/internal/ui/keybinding.go index 5dfa875..e0195ff 100644 --- a/internal/ui/keybinding.go +++ b/internal/ui/keybinding.go @@ -77,13 +77,66 @@ func (a *App) parentDir() { } } -// toggleFocus はペイン間のフォーカスを切り替える。 +// toggleFocus はフォーカスを巡回させる。 +// Sourceペイン → Sourceプレビュー → Destペイン → Destプレビュー の順で、 +// SourcePaneが未選択の場合はSource側をスキップする。 func (a *App) toggleFocus() { a.resetGPending() - a.FocusLeft = !a.FocusLeft + switch { + case !a.FocusLeft && !a.previewFocused: // Destペイン → Destプレビュー + a.previewFocused = true + case !a.FocusLeft && a.previewFocused: // Destプレビュー → Sourceペイン(無ければDestペイン) + a.previewFocused = false + if a.SourcePane != nil { + a.FocusLeft = true + } + case a.FocusLeft && !a.previewFocused: // Sourceペイン → Sourceプレビュー + a.previewFocused = true + default: // Sourceプレビュー → Destペイン + a.FocusLeft = false + a.previewFocused = false + } + a.previewScroll = 0 a.Status = "" } +// handlePreviewKey はプレビューフォーカス時のキー入力を処理する。 +func (a *App) handlePreviewKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "tab": + a.toggleFocus() + 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..98c7785 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) } diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index 153aebf..d778e43 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -108,14 +108,26 @@ func TestUpdate_スペースでマークのみトグルしカーソルは移動 } } -func TestUpdate_Tabでフォーカス切替(t *testing.T) { +func TestUpdate_Tabでフォーカス巡回(t *testing.T) { app := newDestApp() - if app.FocusLeft { - t.Fatal("初期状態は右フォーカスのはず") + app.SourcePane = &pane.Pane{ + Dir: "/src", + Entries: []fs.Entry{{Name: "x"}}, + Cursor: 0, + Selected: map[int]bool{}, + } + if app.FocusLeft || app.previewFocused { + t.Fatal("初期状態はDestペインフォーカスのはず") + } + // Destペイン → Destプレビュー + app.Update(tea.KeyMsg{Type: tea.KeyTab}) + if app.FocusLeft || !app.previewFocused { + t.Errorf("1回目のTabでDestプレビューになるべき: FocusLeft=%v preview=%v", app.FocusLeft, app.previewFocused) } + // Destプレビュー → Sourceペイン app.Update(tea.KeyMsg{Type: tea.KeyTab}) - if !app.FocusLeft { - t.Error("Tab後は左フォーカスになるべき") + if !app.FocusLeft || app.previewFocused { + t.Errorf("2回目のTabでSourceペインになるべき: FocusLeft=%v preview=%v", app.FocusLeft, app.previewFocused) } } diff --git a/internal/ui/preview.go b/internal/ui/preview.go index c1a98ad..f3c6033 100644 --- a/internal/ui/preview.go +++ b/internal/ui/preview.go @@ -14,13 +14,10 @@ func isBinary(data []byte) bool { return bytes.IndexByte(data, 0) >= 0 } -// previewLines はペインのカーソル行に対応するプレビュー内容を行配列で返す。 +// previewContent はペインのカーソル行に対応するプレビュー内容を全行返す。 // テキストファイルは内容、ディレクトリは中の一覧、バイナリや読み込み失敗は -// その旨のメッセージを返す。最大height行・幅widthに切り詰める。 -func (a *App) previewLines(p *pane.Pane, width, height int) []string { - if height < 1 { - height = 1 - } +// その旨のメッセージを返す。各行は幅widthに切り詰める。 +func (a *App) previewContent(p *pane.Pane, width int) []string { if p == nil || len(p.Entries) == 0 { return []string{"(プレビューなし)"} } @@ -33,6 +30,9 @@ func (a *App) previewLines(p *pane.Pane, width, height int) []string { if err != nil { return []string{"(読み込みエラー)"} } + if len(entries) == 0 { + return []string{"(空のディレクトリ)"} + } lines := make([]string, 0, len(entries)) for _, e := range entries { name := e.Name @@ -40,12 +40,6 @@ func (a *App) previewLines(p *pane.Pane, width, height int) []string { name += "/" } lines = append(lines, truncateToWidth(name, width)) - if len(lines) >= height { - break - } - } - if len(lines) == 0 { - return []string{"(空のディレクトリ)"} } return lines } @@ -62,21 +56,42 @@ func (a *App) previewLines(p *pane.Pane, width, height int) []string { } raw := strings.Split(string(data), "\n") - lines := make([]string, 0, height) + 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)) - if len(lines) >= height { - break - } } 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 はフォーカス中ペインのプレビュー領域を枠付きで描画する。 -func (a *App) renderPreview(p *pane.Pane, outerW, outerH int) string { +// focusedがtrueの場合は枠をアクティブ色にする。 +func (a *App) renderPreview(p *pane.Pane, outerW, outerH int, focused bool) string { innerW := outerW - 2 innerH := outerH - 2 if innerW < 1 { @@ -86,9 +101,13 @@ func (a *App) renderPreview(p *pane.Pane, outerW, outerH int) string { innerH = 1 } + borderColor := colorBorderIdle + if focused { + borderColor = colorBorderActive + } boxStyle := lipgloss.NewStyle(). Border(lipgloss.NormalBorder()). - BorderForeground(colorBorderIdle). + BorderForeground(borderColor). Width(innerW). Height(innerH) @@ -97,21 +116,46 @@ func (a *App) renderPreview(p *pane.Pane, outerW, outerH int) string { if bodyHeight < 1 { bodyHeight = 1 } - lines := a.previewLines(p, innerW, bodyHeight) + + 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 lines { + for _, line := range visible { rendered = append(rendered, normalStyle.Render(line)) - if len(rendered) >= bodyHeight { - break - } } for len(rendered) < bodyHeight { rendered = append(rendered, normalStyle.Render("")) } - titleLine := lipgloss.NewStyle().Bold(true).MaxWidth(innerW).Render(" Preview ") - content := titleLine + "\n" + strings.Join(rendered, "\n") - return boxStyle.Render(content) + 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 index 8a59fb0..99fb99e 100644 --- a/internal/ui/preview_test.go +++ b/internal/ui/preview_test.go @@ -4,6 +4,7 @@ import ( "strings" "testing" + tea "github.com/charmbracelet/bubbletea" "github.com/myuron/graftx/internal/fs" "github.com/myuron/graftx/internal/pane" ) @@ -39,7 +40,7 @@ func TestPreviewLines_テキストファイルの内容を行で返す(t *testin Selected: map[int]bool{}, } - lines := app.previewLines(p, 20, 5) + lines := app.previewContent(p, 20) joined := strings.Join(lines, "\n") if !strings.Contains(joined, "hello") || !strings.Contains(joined, "world") { t.Errorf("テキスト内容が含まれていない: %q", joined) @@ -58,7 +59,7 @@ func TestPreviewLines_バイナリは不可メッセージ(t *testing.T) { Selected: map[int]bool{}, } - joined := strings.Join(app.previewLines(p, 20, 5), "\n") + joined := strings.Join(app.previewContent(p, 20), "\n") if !strings.Contains(joined, "バイナリ") { t.Errorf("バイナリ不可メッセージが含まれていない: %q", joined) } @@ -77,7 +78,7 @@ func TestPreviewLines_ディレクトリは中の一覧を返す(t *testing.T) { Selected: map[int]bool{}, } - joined := strings.Join(app.previewLines(p, 20, 5), "\n") + joined := strings.Join(app.previewContent(p, 20), "\n") if !strings.Contains(joined, "sub/") || !strings.Contains(joined, "f.go") { t.Errorf("ディレクトリ一覧が含まれていない: %q", joined) } @@ -92,7 +93,7 @@ func TestPreviewLines_読み込み失敗は不可メッセージ(t *testing.T) { Selected: map[int]bool{}, } - joined := strings.Join(app.previewLines(p, 20, 5), "\n") + joined := strings.Join(app.previewContent(p, 20), "\n") if joined == "" { t.Errorf("読み込み失敗時にメッセージが返るべき") } @@ -101,14 +102,125 @@ func TestPreviewLines_読み込み失敗は不可メッセージ(t *testing.T) { 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.previewLines(p, 20, 5); len(lines) == 0 { + if lines := app.previewContent(p, 20); len(lines) == 0 { t.Errorf("空ペインでも1行以上返すべき") } - if lines := app.previewLines(nil, 20, 5); len(lines) == 0 { + 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, + } + + // 開始: Destペイン + a.toggleFocus() // Destプレビュー + if a.FocusLeft || !a.previewFocused { + t.Fatalf("Destプレビューに遷移すべき: FocusLeft=%v preview=%v", a.FocusLeft, a.previewFocused) + } + a.toggleFocus() // Sourceペイン + if !a.FocusLeft || a.previewFocused { + t.Fatalf("Sourceペインに遷移すべき: FocusLeft=%v preview=%v", a.FocusLeft, a.previewFocused) + } + a.toggleFocus() // Sourceプレビュー + if !a.FocusLeft || !a.previewFocused { + t.Fatalf("Sourceプレビューに遷移すべき: FocusLeft=%v preview=%v", a.FocusLeft, a.previewFocused) + } + a.toggleFocus() // Destペイン + if a.FocusLeft || a.previewFocused { + t.Fatalf("Destペインに戻るべき: FocusLeft=%v preview=%v", a.FocusLeft, a.previewFocused) + } +} + +func TestToggleFocus_Source未選択時はDestのみ巡回(t *testing.T) { + a := &App{DestPane: &pane.Pane{Selected: map[int]bool{}}, FocusLeft: false} + + a.toggleFocus() // Destプレビュー + if a.FocusLeft || !a.previewFocused { + t.Fatalf("Destプレビューに遷移すべき: FocusLeft=%v preview=%v", a.FocusLeft, a.previewFocused) + } + a.toggleFocus() // Destペイン(Sourceはスキップ) + if a.FocusLeft || a.previewFocused { + t.Fatalf("Destペインに戻るべき: FocusLeft=%v preview=%v", a.FocusLeft, a.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.KeyTab}) + if !app.previewFocused { + t.Fatal("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{ diff --git a/internal/ui/render.go b/internal/ui/render.go index fd83086..548337b 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -82,19 +82,24 @@ func (a *App) render() string { leftWidth := a.width / 2 rightWidth := a.width - leftWidth + // プレビュー対象が変わったらスクロール位置をリセットする。 + a.syncPreviewScroll() + // フォーカス側はペインとプレビューに縦分割し、非フォーカス側は全高で描画する。 paneH, previewH := splitPreviewHeight(paneAreaHeight) + // ペインの枠とカーソルはプレビューフォーカス中でないときにアクティブ表示する。 + paneActive := !a.previewFocused var left, right string if a.FocusLeft { - pane := a.renderPane(a.SourcePane, " Source ", true, leftWidth, paneH) - preview := a.renderPreview(a.SourcePane, leftWidth, previewH) + 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 ", true, rightWidth, paneH) - preview := a.renderPreview(a.DestPane, rightWidth, previewH) + 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) } @@ -103,13 +108,14 @@ func (a *App) render() string { } // splitPreviewHeight はフォーカス側の利用可能高さをペインとプレビューに配分する。 -// プレビューは全体の約1/3を占め、双方が最低3行(枠込み)を確保できるよう調整する。 +// プレビューは全体の約50%を占め、双方が最低3行(枠込み)を確保できるよう調整する。 func splitPreviewHeight(total int) (paneH, previewH int) { - previewH = total / 3 + paneH = total / 2 + previewH = total - paneH if previewH < 3 { previewH = 3 + paneH = total - previewH } - paneH = total - previewH if paneH < 3 { paneH = 3 previewH = total - paneH @@ -120,6 +126,25 @@ func splitPreviewHeight(total int) (paneH, previewH int) { 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 { @@ -234,6 +259,9 @@ func (a *App) renderStatus() string { if a.FocusLeft { focus = "left" } + if a.previewFocused { + return fmt.Sprintf(" [%s preview] [q]Quit [Tab]Switch [j/k]Scroll", focus) + } return fmt.Sprintf(" [%s] [q]Quit [Tab]Switch [h/j/k/l]Move", focus) } From 3f0a7148b935a131f6b9ce87a76f032f81b97cac Mon Sep 17 00:00:00 2001 From: myuron Date: Sun, 28 Jun 2026 16:02:59 +0000 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20Shift+Tab=E3=81=A7=E9=80=86?= =?UTF-8?q?=E9=A0=86=E3=81=AB=E3=83=95=E3=82=A9=E3=83=BC=E3=82=AB=E3=82=B9?= =?UTF-8?q?=E5=B7=A1=E5=9B=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tab の逆方向として Shift+Tab を追加。 Destペイン → Sourceプレビュー → Sourceペイン → Destプレビュー の順で 巡回し、SourcePane未選択時はSource側をスキップする。 通常モード・プレビューフォーカス時の双方で有効。 Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/ui/keybinding.go | 25 ++++++++++++++++ internal/ui/model.go | 2 ++ internal/ui/preview_test.go | 59 +++++++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+) diff --git a/internal/ui/keybinding.go b/internal/ui/keybinding.go index e0195ff..cfa0f9d 100644 --- a/internal/ui/keybinding.go +++ b/internal/ui/keybinding.go @@ -100,11 +100,36 @@ func (a *App) toggleFocus() { a.Status = "" } +// toggleFocusBack はフォーカスをtoggleFocusと逆順に巡回させる。 +// Destプレビュー → Destペイン → Sourceプレビュー → Sourceペイン の順で、 +// SourcePaneが未選択の場合はSource側をスキップする。 +func (a *App) toggleFocusBack() { + a.resetGPending() + switch { + case !a.FocusLeft && !a.previewFocused: // Destペイン → Sourceプレビュー(無ければDestプレビュー) + a.previewFocused = true + if a.SourcePane != nil { + a.FocusLeft = true + } + case !a.FocusLeft && a.previewFocused: // Destプレビュー → Destペイン + a.previewFocused = false + case a.FocusLeft && a.previewFocused: // Sourceプレビュー → Sourceペイン + a.previewFocused = false + default: // Sourceペイン → Destプレビュー + a.FocusLeft = false + a.previewFocused = true + } + a.previewScroll = 0 + 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.toggleFocusBack() case "j", "down": a.resetGPending() a.previewScrollDown() diff --git a/internal/ui/model.go b/internal/ui/model.go index 98c7785..3ed59c7 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -69,6 +69,8 @@ func (a *App) handleNormalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { a.parentDir() case "tab": a.toggleFocus() + case "shift+tab": + a.toggleFocusBack() case "s": return a, a.selectRepo() case "q": diff --git a/internal/ui/preview_test.go b/internal/ui/preview_test.go index 99fb99e..232895b 100644 --- a/internal/ui/preview_test.go +++ b/internal/ui/preview_test.go @@ -166,6 +166,65 @@ func TestToggleFocus_プレビューを含めて巡回する(t *testing.T) { } } +func TestToggleFocusBack_逆順に巡回する(t *testing.T) { + a := &App{ + SourcePane: &pane.Pane{Selected: map[int]bool{}}, + DestPane: &pane.Pane{Selected: map[int]bool{}}, + FocusLeft: false, + } + + // 開始: Destペイン → Sourceプレビュー + a.toggleFocusBack() + if !a.FocusLeft || !a.previewFocused { + t.Fatalf("Sourceプレビューに遷移すべき: FocusLeft=%v preview=%v", a.FocusLeft, a.previewFocused) + } + a.toggleFocusBack() // Sourceペイン + if !a.FocusLeft || a.previewFocused { + t.Fatalf("Sourceペインに遷移すべき: FocusLeft=%v preview=%v", a.FocusLeft, a.previewFocused) + } + a.toggleFocusBack() // Destプレビュー + if a.FocusLeft || !a.previewFocused { + t.Fatalf("Destプレビューに遷移すべき: FocusLeft=%v preview=%v", a.FocusLeft, a.previewFocused) + } + a.toggleFocusBack() // Destペイン + if a.FocusLeft || a.previewFocused { + t.Fatalf("Destペインに戻るべき: FocusLeft=%v preview=%v", a.FocusLeft, a.previewFocused) + } +} + +func TestToggleFocusBack_Source未選択時はDestのみ巡回(t *testing.T) { + a := &App{DestPane: &pane.Pane{Selected: map[int]bool{}}, FocusLeft: false} + + a.toggleFocusBack() // Destプレビュー + if a.FocusLeft || !a.previewFocused { + t.Fatalf("Destプレビューに遷移すべき: FocusLeft=%v preview=%v", a.FocusLeft, a.previewFocused) + } + a.toggleFocusBack() // Destペイン + if a.FocusLeft || a.previewFocused { + t.Fatalf("Destペインに戻るべき: FocusLeft=%v preview=%v", a.FocusLeft, a.previewFocused) + } +} + +func TestUpdate_ShiftTabで逆巡回(t *testing.T) { + app := newDestApp() + app.SourcePane = &pane.Pane{ + Dir: "/src", + Entries: []fs.Entry{{Name: "x"}}, + Cursor: 0, + Selected: map[int]bool{}, + } + // Destペイン → Sourceプレビュー + app.Update(tea.KeyMsg{Type: tea.KeyShiftTab}) + if !app.FocusLeft || !app.previewFocused { + t.Errorf("Shift+TabでSourceプレビューになるべき: FocusLeft=%v preview=%v", app.FocusLeft, app.previewFocused) + } + // プレビューフォーカス中でもShift+Tabで逆巡回できる: Sourceプレビュー → Sourceペイン + app.Update(tea.KeyMsg{Type: tea.KeyShiftTab}) + if !app.FocusLeft || app.previewFocused { + t.Errorf("Shift+TabでSourceペインになるべき: FocusLeft=%v preview=%v", app.FocusLeft, app.previewFocused) + } +} + func TestToggleFocus_Source未選択時はDestのみ巡回(t *testing.T) { a := &App{DestPane: &pane.Pane{Selected: map[int]bool{}}, FocusLeft: false} From 133416ac5f181db016941e7fc2916e575cd1fcd1 Mon Sep 17 00:00:00 2001 From: myuron Date: Sun, 28 Jun 2026 16:07:09 +0000 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20Tab=E3=82=92=E6=A8=AA=E7=A7=BB?= =?UTF-8?q?=E5=8B=95=E3=83=BBShift+Tab=E3=82=92=E7=B8=A6=E7=A7=BB=E5=8B=95?= =?UTF-8?q?=E3=81=AB=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit フォーカス移動を2軸に分離。 - Tab: 左右ペイン間の横移動(縦位置のペイン/プレビューは維持) - Shift+Tab: ペインとプレビュー間の縦移動(左右位置は維持) SourcePane未選択時はTabでの横移動を無効化する。 Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/ui/keybinding.go | 46 +++----------- internal/ui/model.go | 2 +- internal/ui/model_test.go | 18 +++--- internal/ui/preview_test.go | 119 +++++++++++++++++------------------- internal/ui/render.go | 4 +- 5 files changed, 76 insertions(+), 113 deletions(-) diff --git a/internal/ui/keybinding.go b/internal/ui/keybinding.go index cfa0f9d..898678a 100644 --- a/internal/ui/keybinding.go +++ b/internal/ui/keybinding.go @@ -77,49 +77,21 @@ func (a *App) parentDir() { } } -// toggleFocus はフォーカスを巡回させる。 -// Sourceペイン → Sourceプレビュー → Destペイン → Destプレビュー の順で、 -// SourcePaneが未選択の場合はSource側をスキップする。 +// toggleFocus はペイン間(左右)のフォーカスを切り替える(横移動)。 +// 縦位置(ペイン/プレビュー)は維持する。SourcePaneが未選択の場合は移動しない。 func (a *App) toggleFocus() { a.resetGPending() - switch { - case !a.FocusLeft && !a.previewFocused: // Destペイン → Destプレビュー - a.previewFocused = true - case !a.FocusLeft && a.previewFocused: // Destプレビュー → Sourceペイン(無ければDestペイン) - a.previewFocused = false - if a.SourcePane != nil { - a.FocusLeft = true - } - case a.FocusLeft && !a.previewFocused: // Sourceペイン → Sourceプレビュー - a.previewFocused = true - default: // Sourceプレビュー → Destペイン - a.FocusLeft = false - a.previewFocused = false + if a.SourcePane != nil { + a.FocusLeft = !a.FocusLeft } - a.previewScroll = 0 a.Status = "" } -// toggleFocusBack はフォーカスをtoggleFocusと逆順に巡回させる。 -// Destプレビュー → Destペイン → Sourceプレビュー → Sourceペイン の順で、 -// SourcePaneが未選択の場合はSource側をスキップする。 -func (a *App) toggleFocusBack() { +// toggleFocusVertical はペインとプレビュー間(上下)のフォーカスを切り替える(縦移動)。 +// 左右位置は維持する。 +func (a *App) toggleFocusVertical() { a.resetGPending() - switch { - case !a.FocusLeft && !a.previewFocused: // Destペイン → Sourceプレビュー(無ければDestプレビュー) - a.previewFocused = true - if a.SourcePane != nil { - a.FocusLeft = true - } - case !a.FocusLeft && a.previewFocused: // Destプレビュー → Destペイン - a.previewFocused = false - case a.FocusLeft && a.previewFocused: // Sourceプレビュー → Sourceペイン - a.previewFocused = false - default: // Sourceペイン → Destプレビュー - a.FocusLeft = false - a.previewFocused = true - } - a.previewScroll = 0 + a.previewFocused = !a.previewFocused a.Status = "" } @@ -129,7 +101,7 @@ func (a *App) handlePreviewKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "tab": a.toggleFocus() case "shift+tab": - a.toggleFocusBack() + a.toggleFocusVertical() case "j", "down": a.resetGPending() a.previewScrollDown() diff --git a/internal/ui/model.go b/internal/ui/model.go index 3ed59c7..5eb60a8 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -70,7 +70,7 @@ func (a *App) handleNormalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "tab": a.toggleFocus() case "shift+tab": - a.toggleFocusBack() + 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 d778e43..e0354cb 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -108,7 +108,7 @@ func TestUpdate_スペースでマークのみトグルしカーソルは移動 } } -func TestUpdate_Tabでフォーカス巡回(t *testing.T) { +func TestUpdate_Tabで左右ペインを切り替え(t *testing.T) { app := newDestApp() app.SourcePane = &pane.Pane{ Dir: "/src", @@ -116,18 +116,18 @@ func TestUpdate_Tabでフォーカス巡回(t *testing.T) { Cursor: 0, Selected: map[int]bool{}, } - if app.FocusLeft || app.previewFocused { - t.Fatal("初期状態はDestペインフォーカスのはず") + if app.FocusLeft { + t.Fatal("初期状態は右(Dest)フォーカスのはず") } - // Destペイン → Destプレビュー + // Destペイン → Sourceペイン(横移動) app.Update(tea.KeyMsg{Type: tea.KeyTab}) - if app.FocusLeft || !app.previewFocused { - t.Errorf("1回目のTabでDestプレビューになるべき: FocusLeft=%v preview=%v", app.FocusLeft, app.previewFocused) + if !app.FocusLeft { + t.Errorf("Tabで左ペインになるべき: FocusLeft=%v", app.FocusLeft) } - // Destプレビュー → Sourceペイン + // Sourceペイン → Destペイン app.Update(tea.KeyMsg{Type: tea.KeyTab}) - if !app.FocusLeft || app.previewFocused { - t.Errorf("2回目のTabでSourceペインになるべき: FocusLeft=%v preview=%v", app.FocusLeft, app.previewFocused) + if app.FocusLeft { + t.Errorf("Tabで右ペインに戻るべき: FocusLeft=%v", app.FocusLeft) } } diff --git a/internal/ui/preview_test.go b/internal/ui/preview_test.go index 232895b..03d82ff 100644 --- a/internal/ui/preview_test.go +++ b/internal/ui/preview_test.go @@ -140,72 +140,75 @@ func TestPreviewScroll_可視範囲が総行数以上ならスクロールしな } } -func TestToggleFocus_プレビューを含めて巡回する(t *testing.T) { +func TestToggleFocus_横方向に左右ペインを切り替える(t *testing.T) { a := &App{ SourcePane: &pane.Pane{Selected: map[int]bool{}}, DestPane: &pane.Pane{Selected: map[int]bool{}}, FocusLeft: false, } - // 開始: Destペイン - a.toggleFocus() // Destプレビュー - if a.FocusLeft || !a.previewFocused { - t.Fatalf("Destプレビューに遷移すべき: FocusLeft=%v preview=%v", a.FocusLeft, a.previewFocused) + a.toggleFocus() // Dest → Source + if !a.FocusLeft { + t.Fatalf("横移動で左ペインになるべき: FocusLeft=%v", a.FocusLeft) } - a.toggleFocus() // Sourceペイン - if !a.FocusLeft || a.previewFocused { - t.Fatalf("Sourceペインに遷移すべき: FocusLeft=%v preview=%v", a.FocusLeft, a.previewFocused) + a.toggleFocus() // Source → Dest + if a.FocusLeft { + t.Fatalf("横移動で右ペインに戻るべき: FocusLeft=%v", a.FocusLeft) } - a.toggleFocus() // Sourceプレビュー + + // 縦位置(プレビュー)は横移動で維持される + a.previewFocused = true + a.toggleFocus() if !a.FocusLeft || !a.previewFocused { - t.Fatalf("Sourceプレビューに遷移すべき: FocusLeft=%v preview=%v", a.FocusLeft, a.previewFocused) - } - a.toggleFocus() // Destペイン - if a.FocusLeft || a.previewFocused { - t.Fatalf("Destペインに戻るべき: FocusLeft=%v preview=%v", a.FocusLeft, a.previewFocused) + t.Fatalf("横移動でプレビュー状態を維持すべき: FocusLeft=%v preview=%v", a.FocusLeft, a.previewFocused) } } -func TestToggleFocusBack_逆順に巡回する(t *testing.T) { - a := &App{ - SourcePane: &pane.Pane{Selected: map[int]bool{}}, - DestPane: &pane.Pane{Selected: map[int]bool{}}, - FocusLeft: false, - } +func TestToggleFocus_Source未選択時は横移動しない(t *testing.T) { + a := &App{DestPane: &pane.Pane{Selected: map[int]bool{}}, FocusLeft: false} - // 開始: Destペイン → Sourceプレビュー - a.toggleFocusBack() - if !a.FocusLeft || !a.previewFocused { - t.Fatalf("Sourceプレビューに遷移すべき: FocusLeft=%v preview=%v", a.FocusLeft, a.previewFocused) + a.toggleFocus() + if a.FocusLeft { + t.Fatalf("Source未選択時は左に移動しないべき: FocusLeft=%v", a.FocusLeft) } - a.toggleFocusBack() // Sourceペイン - if !a.FocusLeft || a.previewFocused { - t.Fatalf("Sourceペインに遷移すべき: FocusLeft=%v preview=%v", a.FocusLeft, a.previewFocused) +} + +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.toggleFocusBack() // Destプレビュー - if a.FocusLeft || !a.previewFocused { - t.Fatalf("Destプレビューに遷移すべき: FocusLeft=%v preview=%v", a.FocusLeft, a.previewFocused) + a.toggleFocusVertical() // プレビュー → ペイン + if a.previewFocused { + t.Fatalf("縦移動でペインに戻るべき: preview=%v", a.previewFocused) } - a.toggleFocusBack() // Destペイン - if a.FocusLeft || a.previewFocused { - t.Fatalf("Destペインに戻るべき: FocusLeft=%v preview=%v", a.FocusLeft, a.previewFocused) + + // 左右位置は縦移動で維持される + a.FocusLeft = true + a.toggleFocusVertical() + if !a.FocusLeft || !a.previewFocused { + t.Fatalf("縦移動で左右位置を維持すべき: FocusLeft=%v preview=%v", a.FocusLeft, a.previewFocused) } } -func TestToggleFocusBack_Source未選択時はDestのみ巡回(t *testing.T) { - a := &App{DestPane: &pane.Pane{Selected: map[int]bool{}}, FocusLeft: false} +func TestUpdate_ShiftTabで縦移動(t *testing.T) { + app := newDestApp() - a.toggleFocusBack() // Destプレビュー - if a.FocusLeft || !a.previewFocused { - t.Fatalf("Destプレビューに遷移すべき: FocusLeft=%v preview=%v", a.FocusLeft, a.previewFocused) + // Destペイン → Destプレビュー + app.Update(tea.KeyMsg{Type: tea.KeyShiftTab}) + if !app.previewFocused { + t.Errorf("Shift+Tabでプレビューにフォーカスするべき: preview=%v", app.previewFocused) } - a.toggleFocusBack() // Destペイン - if a.FocusLeft || a.previewFocused { - t.Fatalf("Destペインに戻るべき: FocusLeft=%v preview=%v", a.FocusLeft, a.previewFocused) + // Destプレビュー → Destペイン + app.Update(tea.KeyMsg{Type: tea.KeyShiftTab}) + if app.previewFocused { + t.Errorf("Shift+Tabでペインに戻るべき: preview=%v", app.previewFocused) } } -func TestUpdate_ShiftTabで逆巡回(t *testing.T) { +func TestUpdate_プレビューフォーカス中のTabは横移動して維持(t *testing.T) { app := newDestApp() app.SourcePane = &pane.Pane{ Dir: "/src", @@ -213,28 +216,16 @@ func TestUpdate_ShiftTabで逆巡回(t *testing.T) { Cursor: 0, Selected: map[int]bool{}, } - // Destペイン → Sourceプレビュー - app.Update(tea.KeyMsg{Type: tea.KeyShiftTab}) - if !app.FocusLeft || !app.previewFocused { - t.Errorf("Shift+TabでSourceプレビューになるべき: FocusLeft=%v preview=%v", app.FocusLeft, app.previewFocused) - } - // プレビューフォーカス中でもShift+Tabで逆巡回できる: Sourceプレビュー → Sourceペイン - app.Update(tea.KeyMsg{Type: tea.KeyShiftTab}) - if !app.FocusLeft || app.previewFocused { - t.Errorf("Shift+TabでSourceペインになるべき: FocusLeft=%v preview=%v", app.FocusLeft, app.previewFocused) - } -} - -func TestToggleFocus_Source未選択時はDestのみ巡回(t *testing.T) { - a := &App{DestPane: &pane.Pane{Selected: map[int]bool{}}, FocusLeft: false} - a.toggleFocus() // Destプレビュー - if a.FocusLeft || !a.previewFocused { - t.Fatalf("Destプレビューに遷移すべき: FocusLeft=%v preview=%v", a.FocusLeft, a.previewFocused) + // Destプレビューにフォーカス + app.Update(tea.KeyMsg{Type: tea.KeyShiftTab}) + if app.FocusLeft || !app.previewFocused { + t.Fatalf("Destプレビューになるべき: FocusLeft=%v preview=%v", app.FocusLeft, app.previewFocused) } - a.toggleFocus() // Destペイン(Sourceはスキップ) - if a.FocusLeft || a.previewFocused { - t.Fatalf("Destペインに戻るべき: FocusLeft=%v preview=%v", a.FocusLeft, a.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) } } @@ -259,9 +250,9 @@ func TestUpdate_プレビューフォーカス時にjでスクロールする(t app.height = 24 // プレビューにフォーカス(Destペイン → Destプレビュー) - app.Update(tea.KeyMsg{Type: tea.KeyTab}) + app.Update(tea.KeyMsg{Type: tea.KeyShiftTab}) if !app.previewFocused { - t.Fatal("Tabでプレビューにフォーカスするべき") + t.Fatal("Shift+Tabでプレビューにフォーカスするべき") } // 描画で総行数・可視行数を確定させる app.View() diff --git a/internal/ui/render.go b/internal/ui/render.go index 548337b..6201900 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -260,9 +260,9 @@ func (a *App) renderStatus() string { focus = "left" } if a.previewFocused { - return fmt.Sprintf(" [%s preview] [q]Quit [Tab]Switch [j/k]Scroll", focus) + return fmt.Sprintf(" [%s preview] [q]Quit [Tab]Pane [S-Tab]Pane/Preview [j/k]Scroll", focus) } - return fmt.Sprintf(" [%s] [q]Quit [Tab]Switch [h/j/k/l]Move", focus) + return fmt.Sprintf(" [%s] [q]Quit [Tab]Pane [S-Tab]Preview [h/j/k/l]Move", focus) } // renderSelectorModal はリポジトリ選択ポップアップのモーダルを描画する。