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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,6 @@

# OS
.DS_Store

# nix build output
result
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@
以下のワークフローに準拠してください。
1. 失敗するテストを書く
2. テストをパスするコードを書く
3. フォーマッター
3. フォーマッター
4. リンター
5. ユニットテスト
3. リファクタリングする
6. リファクタリングする

## 開発言語やライブラリ

Expand Down
6 changes: 3 additions & 3 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ ghqでリポジトリを管理しているCLIユーザー。

### 前提条件

- `ghq` がインストール済みであること
- `fzf` がインストール済みであること
- `ghq` がインストール済みであること(リポジトリ一覧の取得に使用)
- `fzf` は任意。リポジトリ選択はアプリ内モーダルで行うため必須ではない

### 技術スタック

Expand Down Expand Up @@ -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ライブラリに依存しない |

### コンポーネント間の依存関係

Expand Down
14 changes: 14 additions & 0 deletions internal/ui/keybinding.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
20 changes: 19 additions & 1 deletion internal/ui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 はキー入力を現在のモードに応じてルーティングする。
Expand Down Expand Up @@ -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":
Expand Down
103 changes: 97 additions & 6 deletions internal/ui/model_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package ui

import (
"errors"
"strings"
"testing"

Expand All @@ -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)
Expand Down Expand Up @@ -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() != "" {
Expand Down
36 changes: 32 additions & 4 deletions internal/ui/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 は画面全体を文字列として描画する。
Expand Down
6 changes: 6 additions & 0 deletions internal/ui/render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
46 changes: 37 additions & 9 deletions internal/ui/selector.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 はリポジトリ選択ポップアップを閉じる。
Expand Down
1 change: 0 additions & 1 deletion result

This file was deleted.

Loading