From 7616a4f96fbcae38e29633ace8967185c355f025 Mon Sep 17 00:00:00 2001 From: myuron Date: Sun, 28 Jun 2026 15:40:09 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20CodeRabbit=E3=83=AC=E3=83=93=E3=83=A5?= =?UTF-8?q?=E3=83=BC=E6=8C=87=E6=91=98=E3=81=AE=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ?による後方検索の入口を追加し検索方向を確定時に保存 - pasteOverwriteで同一パスへの上書きコピーをガード - textinputの非キーメッセージをUpdateで入力欄へ委譲しカーソル点滅を維持 - highlightMatchをrune境界ベースに変更しマルチバイト分割を防止 - ListRepositoriesをtea.Cmdで非同期化(repoListMsg導入) - docs/design.md/CLAUDE.mdの軽微な記述・番号付けを修正 - 誤コミットしたnix成果物resultを削除し.gitignoreに追加 Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 3 ++ CLAUDE.md | 4 +- docs/design.md | 6 +-- internal/ui/keybinding.go | 14 +++++ internal/ui/model.go | 20 ++++++- internal/ui/model_test.go | 103 ++++++++++++++++++++++++++++++++++--- internal/ui/render.go | 36 +++++++++++-- internal/ui/render_test.go | 6 +++ internal/ui/selector.go | 46 +++++++++++++---- result | 1 - 10 files changed, 213 insertions(+), 26 deletions(-) delete mode 120000 result diff --git a/.gitignore b/.gitignore index 2785a1e..7d82617 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ # OS .DS_Store + +# nix build output +result diff --git a/CLAUDE.md b/CLAUDE.md index 60eb366..975b9f6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,10 +12,10 @@ 以下のワークフローに準拠してください。 1. 失敗するテストを書く 2. テストをパスするコードを書く -3. フォーマッター +3. フォーマッター 4. リンター 5. ユニットテスト -3. リファクタリングする +6. リファクタリングする ## 開発言語やライブラリ diff --git a/docs/design.md b/docs/design.md index f40f092..5c74a13 100644 --- a/docs/design.md +++ b/docs/design.md @@ -25,8 +25,8 @@ ghqでリポジトリを管理しているCLIユーザー。 ### 前提条件 -- `ghq` がインストール済みであること -- `fzf` がインストール済みであること +- `ghq` がインストール済みであること(リポジトリ一覧の取得に使用) +- `fzf` は任意。リポジトリ選択はアプリ内モーダルで行うため必須ではない ### 技術スタック @@ -88,7 +88,7 @@ graftx/ | `internal/ui` | `tea.Model`(`App`構造体)の実装。`Update`でキー入力を処理し、`View`で画面を描画する。アプリケーション全体の状態を保持する。TUIライブラリに直接依存する唯一のパッケージ | | `internal/pane` | ペインの状態管理。現在のディレクトリパス、エントリ一覧、カーソル位置、選択中ファイル一覧、`YankBuffer`を保持。TUIライブラリに依存しない | | `internal/fs` | ファイルシステム操作。`FileSystem`インターフェースの定義と実装。ディレクトリのエントリ一覧取得、ファイル/ディレクトリのコピー、削除を提供。`Entry`構造体もここで定義する。TUIライブラリに依存しない | -| `internal/selector` | `ghq list | fzf` の実行とリポジトリパスの取得。`CommandRunner`インターフェースの定義と実装。TUIライブラリに依存しない | +| `internal/selector` | `ghq list \| fzf` の実行とリポジトリパスの取得。`CommandRunner`インターフェースの定義と実装。TUIライブラリに依存しない | ### コンポーネント間の依存関係 diff --git a/internal/ui/keybinding.go b/internal/ui/keybinding.go index 312dcea..5dfa875 100644 --- a/internal/ui/keybinding.go +++ b/internal/ui/keybinding.go @@ -269,6 +269,11 @@ func (a *App) pasteOverwrite() { for _, src := range a.YankBuf.Entries { name := filepath.Base(src) dstPath := filepath.Join(dst.Dir, name) + // コピー元と同一パスへの上書きはコピー元を破壊しうるため防ぐ + if filepath.Clean(src) == filepath.Clean(dstPath) { + a.Status = "copy error: source and destination are the same" + return + } if err := a.FS.Copy(src, dstPath); err != nil { a.Status = fmt.Sprintf("copy error: %v", err) return @@ -386,6 +391,13 @@ func (a *App) searchForward() tea.Cmd { return a.enterTextInput(InputModeSearch, "") } +// searchBackward は後方検索モードに入る。 +func (a *App) searchBackward() tea.Cmd { + a.resetGPending() + a.searchFwd = false + return a.enterTextInput(InputModeSearchBackward, "") +} + // searchNext は次の検索結果に移動する。 func (a *App) searchNext() { a.resetGPending() @@ -449,6 +461,8 @@ func (a *App) inputSubmit() { switch mode { case InputModeSearch, InputModeSearchBackward: a.searchQuery = input + // 確定時に検索方向を保存し、後続のn/Nが正しい方向を使えるようにする + a.searchFwd = mode == InputModeSearch if input == "" { a.Status = "" return diff --git a/internal/ui/model.go b/internal/ui/model.go index 7410812..e5336fd 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -18,10 +18,26 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.width = msg.Width a.height = msg.Height return a, nil + case repoListMsg: + return a.handleRepoList(msg) case tea.KeyMsg: return a.handleKey(msg) + default: + // カーソル点滅など非キー系メッセージをアクティブな入力欄に流す + return a.forwardToInput(msg) } - return a, nil +} + +// forwardToInput は現在のモードに応じて非キーメッセージを入力欄へ委譲する。 +func (a *App) forwardToInput(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + switch { + case a.inputMode == InputModeSelectRepo: + a.filterInput, cmd = a.filterInput.Update(msg) + case a.isTextInputMode(): + a.input, cmd = a.input.Update(msg) + } + return a, cmd } // handleKey はキー入力を現在のモードに応じてルーティングする。 @@ -83,6 +99,8 @@ func (a *App) handleNormalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return a, a.renameFile() case "/": return a, a.searchForward() + case "?": + return a, a.searchBackward() case "n": a.searchNext() case "N": diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index 0bd688c..b087d13 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -1,6 +1,7 @@ package ui import ( + "errors" "strings" "testing" @@ -9,24 +10,40 @@ import ( "github.com/myuron/graftx/internal/pane" ) +// errStub はテスト用のダミーエラー。 +var errStub = errors.New("stub error") + // keyRunes はルーン入力のキーメッセージを生成する。 func keyRunes(s string) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} } -// stubFS は削除系テスト用の最小のFileSystem実装。 +// stubFS は削除・コピー系テスト用の最小のFileSystem実装。 type stubFS struct { entries []fs.Entry trashed []string removed []string + copies [][2]string // [src, dst] の記録 } func (s *stubFS) ReadDir(string) ([]fs.Entry, error) { return s.entries, nil } -func (s *stubFS) Copy(string, string) error { return nil } -func (s *stubFS) Remove(p string) error { s.removed = append(s.removed, p); return nil } -func (s *stubFS) Trash(p string) error { s.trashed = append(s.trashed, p); return nil } -func (s *stubFS) Rename(string, string) error { return nil } -func (s *stubFS) Create(string, bool) error { return nil } +func (s *stubFS) Copy(src, dst string) error { + s.copies = append(s.copies, [2]string{src, dst}) + return nil +} +func (s *stubFS) Remove(p string) error { s.removed = append(s.removed, p); return nil } +func (s *stubFS) Trash(p string) error { s.trashed = append(s.trashed, p); return nil } +func (s *stubFS) Rename(string, string) error { return nil } +func (s *stubFS) Create(string, bool) error { return nil } + +// stubRunner はリポジトリ選択テスト用のCommandRunner実装。 +type stubRunner struct { + repos []string + err error +} + +func (r *stubRunner) SelectRepository() (string, error) { return "", nil } +func (r *stubRunner) ListRepositories() ([]string, error) { return r.repos, r.err } func newDestApp() *App { app, _ := NewApp("/dst", &stubFS{}, nil) @@ -176,6 +193,80 @@ func TestUpdate_削除確認_nでキャンセル(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) + } + + // "apple" を入力して確定 → 後方検索でapple(idx0)にジャンプ + for _, ch := range []string{"a", "p", "p", "l", "e"} { + app.Update(keyRunes(ch)) + } + app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + + if app.searchFwd { + t.Error("後方検索確定後はsearchFwdがfalseであるべき") + } + if app.searchQuery != "apple" { + t.Errorf("searchQuery = %q, want \"apple\"", app.searchQuery) + } + if app.DestPane.Cursor != 0 { + t.Errorf("Cursor = %d, want 0 (apple)", app.DestPane.Cursor) + } +} + +func TestPasteOverwrite_同一パスへのコピーを防ぐ(t *testing.T) { + stub := &stubFS{} + app, _ := NewApp("/dst", stub, nil) + app.DestPane = &pane.Pane{Dir: "/dst", Entries: []fs.Entry{{Name: "apple"}}, Selected: map[int]bool{}, FS: stub} + app.width, app.height = 80, 24 + // ヤンク元と貼り付け先が同一ディレクトリの同名ファイル + app.YankBuf = &pane.YankBuffer{Entries: []string{"/dst/apple"}, SrcDir: "/dst"} + + app.pasteOverwrite() + + if len(stub.copies) != 0 { + t.Errorf("同一パスではCopyを呼ばないべき: %v", stub.copies) + } + if app.Status != "copy error: source and destination are the same" { + t.Errorf("Status = %q", app.Status) + } +} + +func TestUpdate_sでセレクタを開き非同期で一覧反映(t *testing.T) { + app := newDestApp() + app.Selector = &stubRunner{repos: []string{"/r/a", "/r/b"}} + + // 's' でセレクタを開く(この時点では一覧はまだ空・ローディング表示) + app.Update(keyRunes("s")) + if app.inputMode != InputModeSelectRepo { + t.Fatalf("inputMode = %d, want InputModeSelectRepo", app.inputMode) + } + if len(app.repoList) != 0 { + t.Errorf("開いた直後はrepoListは空のはず: %v", app.repoList) + } + + // 非同期取得結果が届くと一覧へ反映される + app.Update(repoListMsg{repos: []string{"/r/a", "/r/b"}}) + if len(app.filteredRepoList) != 2 { + t.Errorf("filteredRepoList len = %d, want 2", len(app.filteredRepoList)) + } +} + +func TestUpdate_セレクタ取得エラーで閉じる(t *testing.T) { + app := newDestApp() + app.inputMode = InputModeSelectRepo + app.Update(repoListMsg{err: errStub}) + if app.inputMode != InputModeNone { + t.Errorf("エラー時はセレクタを閉じるべき: inputMode = %d", app.inputMode) + } +} + func TestView_サイズ未設定なら空文字(t *testing.T) { app, _ := NewApp("/dst", &stubFS{}, nil) if app.View() != "" { diff --git a/internal/ui/render.go b/internal/ui/render.go index bcc5bb4..bbc6e03 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -20,18 +20,46 @@ var ( // highlightMatch は検索クエリにマッチする部分をANSIカラーでハイライトする。 // 大文字小文字を無視してマッチし、最初のマッチ部分を黄色背景+黒文字で装飾する。 // クエリが空またはマッチなしの場合はそのまま返す。 +// マルチバイト文字を分割しないようrune境界で処理する。 func highlightMatch(name, query string) string { if query == "" { return name } - lower := strings.ToLower(name) - q := strings.ToLower(query) - idx := strings.Index(lower, q) + orig := []rune(name) + lower := []rune(strings.ToLower(name)) + q := []rune(strings.ToLower(query)) + idx := runeSliceIndex(lower, q) if idx < 0 { return name } + end := idx + len(q) + // ToLowerでrune数が変わる稀なケースに備えて範囲を検証する + if end > len(orig) { + return name + } // 元の文字列の大文字小文字を保持してハイライト - return name[:idx] + "\x1b[30;43m" + name[idx:idx+len(query)] + "\x1b[0m" + name[idx+len(query):] + return string(orig[:idx]) + "\x1b[30;43m" + string(orig[idx:end]) + "\x1b[0m" + string(orig[end:]) +} + +// runeSliceIndex はruneスライスsの中でsubが最初に現れる位置(rune単位)を返す。 +// 見つからなければ-1を返す。 +func runeSliceIndex(s, sub []rune) int { + if len(sub) == 0 { + return 0 + } + for i := 0; i+len(sub) <= len(s); i++ { + match := true + for j := range sub { + if s[i+j] != sub[j] { + match = false + break + } + } + if match { + return i + } + } + return -1 } // render は画面全体を文字列として描画する。 diff --git a/internal/ui/render_test.go b/internal/ui/render_test.go index dd557a0..e01bfd1 100644 --- a/internal/ui/render_test.go +++ b/internal/ui/render_test.go @@ -58,6 +58,12 @@ func TestHighlightMatch_マッチ部分を黄色背景でハイライト(t *test query: "abc", want: "\x1b[30;43mabc\x1b[0m" + "abc", }, + { + name: "マルチバイト文字を分割しない", + input: "あいうえお", + query: "いう", + want: "あ" + "\x1b[30;43mいう\x1b[0m" + "えお", + }, } for _, tt := range tests { diff --git a/internal/ui/selector.go b/internal/ui/selector.go index 29507cb..7efb383 100644 --- a/internal/ui/selector.go +++ b/internal/ui/selector.go @@ -67,21 +67,49 @@ func (a *App) handleSelectorKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return a, cmd } +// repoListMsg はリポジトリ一覧の非同期取得結果を伝えるメッセージ。 +type repoListMsg struct { + repos []string + err error +} + // openRepoSelector はリポジトリ選択ポップアップを開く。 +// リポジトリ一覧の取得はブロッキングを避けるためtea.Cmdで非同期に行う。 func (a *App) openRepoSelector() tea.Cmd { - repos, err := a.Selector.ListRepositories() - if err != nil { - a.Status = fmt.Sprintf("failed to list repositories: %v", err) - return nil - } - - a.repoList = repos + a.repoList = nil + a.filteredRepoList = nil a.repoFilterQuery = "" a.repoSelectorCursor = 0 - a.filterRepoList() a.inputMode = InputModeSelectRepo a.filterInput.SetValue("") - return a.filterInput.Focus() + a.Status = "loading repositories..." + return tea.Batch(a.filterInput.Focus(), a.fetchRepos()) +} + +// fetchRepos はリポジトリ一覧を非同期取得するtea.Cmdを返す。 +func (a *App) fetchRepos() tea.Cmd { + sel := a.Selector + return func() tea.Msg { + repos, err := sel.ListRepositories() + return repoListMsg{repos: repos, err: err} + } +} + +// handleRepoList はリポジトリ一覧取得結果を処理する。 +func (a *App) handleRepoList(msg repoListMsg) (tea.Model, tea.Cmd) { + // 取得完了前にポップアップが閉じられていれば無視する + if a.inputMode != InputModeSelectRepo { + return a, nil + } + if msg.err != nil { + a.Status = fmt.Sprintf("failed to list repositories: %v", msg.err) + a.closeRepoSelector() + return a, nil + } + a.repoList = msg.repos + a.filterRepoList() + a.Status = "" + return a, nil } // closeRepoSelector はリポジトリ選択ポップアップを閉じる。 diff --git a/result b/result deleted file mode 120000 index 2cf7c41..0000000 --- a/result +++ /dev/null @@ -1 +0,0 @@ -/nix/store/xb8v2ix2bv5a5vdi35kkk6aflv1wpyz9-graftx-0.1.0 \ No newline at end of file