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
29 changes: 18 additions & 11 deletions .claude/plans/plan.md
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions internal/fs/fs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 はファイル/ディレクトリを完全削除する。
Expand Down
16 changes: 16 additions & 0 deletions internal/fs/osfs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions internal/pane/pane_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
54 changes: 52 additions & 2 deletions internal/ui/keybinding.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
6 changes: 6 additions & 0 deletions internal/ui/layout.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 // リポジトリ選択カーソル位置
Expand Down
4 changes: 4 additions & 0 deletions internal/ui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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":
Expand Down
27 changes: 23 additions & 4 deletions internal/ui/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}

Expand Down
161 changes: 161 additions & 0 deletions internal/ui/preview.go
Original file line number Diff line number Diff line change
@@ -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 "
}
Loading
Loading