diff --git a/internal/config/load.go b/internal/config/load.go index a2d2155048a8abf634958293056da8a4713e1400..5b81fa3085b94cbfb051ddfdf9887e44c6fe5540 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -1,16 +1,19 @@ package config import ( + "cmp" "encoding/json" "fmt" "io" "log/slog" "maps" "os" + "os/user" "path/filepath" "runtime" "slices" "strings" + "sync" "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/csync" @@ -538,13 +541,14 @@ func GlobalConfigData() string { return filepath.Join(os.Getenv("HOME"), ".local", "share", appName, fmt.Sprintf("%s.json", appName)) } -func HomeDir() string { - homeDir := os.Getenv("HOME") - if homeDir == "" { - homeDir = os.Getenv("USERPROFILE") // For Windows compatibility +var HomeDir = sync.OnceValue(func() string { + u, err := user.Current() + if err == nil { + return u.HomeDir } - if homeDir == "" { - homeDir = os.Getenv("HOMEPATH") // Fallback for some environments - } - return homeDir -} + return cmp.Or( + os.Getenv("HOME"), + os.Getenv("USERPROFILE"), + os.Getenv("HOMEPATH"), + ) +}) diff --git a/internal/csync/maps.go b/internal/csync/maps.go index 67796baff9f68b2a02382de625de70b78e204f4a..b7a1f3109f6c15e7e5592cb538943a2d9e340819 100644 --- a/internal/csync/maps.go +++ b/internal/csync/maps.go @@ -56,6 +56,18 @@ func (m *Map[K, V]) Len() int { return len(m.inner) } +// GetOrSet gets and returns the key if it exists, otherwise, it executes the +// given function, set its return value for the given key, and returns it. +func (m *Map[K, V]) GetOrSet(key K, fn func() V) V { + got, ok := m.Get(key) + if ok { + return got + } + value := fn() + m.Set(key, value) + return value +} + // Take gets an item and then deletes it. func (m *Map[K, V]) Take(key K) (V, bool) { m.mu.Lock() diff --git a/internal/csync/maps_test.go b/internal/csync/maps_test.go index 2b1f1387f14a5feae3ad86d77482baaf4494c718..4a8019260a2610b7f5ae0d854029207c6b945d04 100644 --- a/internal/csync/maps_test.go +++ b/internal/csync/maps_test.go @@ -54,6 +54,16 @@ func TestMap_Set(t *testing.T) { require.Equal(t, 1, m.Len()) } +func TestMap_GetOrSet(t *testing.T) { + t.Parallel() + + m := NewMap[string, int]() + + require.Equal(t, 42, m.GetOrSet("key1", func() int { return 42 })) + require.Equal(t, 42, m.GetOrSet("key1", func() int { return 99999 })) + require.Equal(t, 1, m.Len()) +} + func TestMap_Get(t *testing.T) { t.Parallel() diff --git a/internal/fsext/ignore_test.go b/internal/fsext/ignore_test.go index 48a56667d0f3776c4054e8abbeff8eaeb67c9dfe..c2490a062d2e55fc96dda78c597fc867465f032e 100644 --- a/internal/fsext/ignore_test.go +++ b/internal/fsext/ignore_test.go @@ -25,20 +25,8 @@ func TestCrushIgnore(t *testing.T) { // Create a .crushignore file that ignores .log files require.NoError(t, os.WriteFile(".crushignore", []byte("*.log\n"), 0o644)) - // Test DirectoryLister - t.Run("DirectoryLister respects .crushignore", func(t *testing.T) { - dl := NewDirectoryLister(tempDir) - - // Test that .log files are ignored - require.True(t, dl.gitignore == nil, "gitignore should be nil") - require.NotNil(t, dl.crushignore, "crushignore should not be nil") - }) - - // Test FastGlobWalker - t.Run("FastGlobWalker respects .crushignore", func(t *testing.T) { - walker := NewFastGlobWalker(tempDir) - - require.True(t, walker.gitignore == nil, "gitignore should be nil") - require.NotNil(t, walker.crushignore, "crushignore should not be nil") - }) + dl := NewDirectoryLister(tempDir) + require.True(t, dl.shouldIgnore("test2.log", nil), ".log files should be ignored") + require.False(t, dl.shouldIgnore("test1.txt", nil), ".txt files should not be ignored") + require.True(t, dl.shouldIgnore("test3.tmp", nil), ".tmp files should be ignored by common patterns") } diff --git a/internal/fsext/ls.go b/internal/fsext/ls.go index e800e921de74e41a9d46f4d1fafd6fc59bcf65a0..e691c148ca880a17aed410f072fa1cb3078fc2b4 100644 --- a/internal/fsext/ls.go +++ b/internal/fsext/ls.go @@ -1,137 +1,186 @@ package fsext import ( + "log/slog" "os" "path/filepath" + "strings" + "sync" "github.com/charlievieth/fastwalk" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/csync" ignore "github.com/sabhiram/go-gitignore" ) -// CommonIgnorePatterns contains commonly ignored files and directories -var CommonIgnorePatterns = []string{ - // Version control - ".git", - ".svn", - ".hg", - ".bzr", - - // IDE and editor files - ".vscode", - ".idea", - "*.swp", - "*.swo", - "*~", - ".DS_Store", - "Thumbs.db", - - // Build artifacts and dependencies - "node_modules", - "target", - "build", - "dist", - "out", - "bin", - "obj", - "*.o", - "*.so", - "*.dylib", - "*.dll", - "*.exe", - - // Logs and temporary files - "*.log", - "*.tmp", - "*.temp", - ".cache", - ".tmp", - - // Language-specific - "__pycache__", - "*.pyc", - "*.pyo", - ".pytest_cache", - "vendor", - "Cargo.lock", - "package-lock.json", - "yarn.lock", - "pnpm-lock.yaml", - - // OS generated files - ".Trash", - ".Spotlight-V100", - ".fseventsd", - - // Crush - ".crush", -} +// commonIgnorePatterns contains commonly ignored files and directories +var commonIgnorePatterns = sync.OnceValue(func() ignore.IgnoreParser { + return ignore.CompileIgnoreLines( + // Version control + ".git", + ".svn", + ".hg", + ".bzr", + + // IDE and editor files + ".vscode", + ".idea", + "*.swp", + "*.swo", + "*~", + ".DS_Store", + "Thumbs.db", + + // Build artifacts and dependencies + "node_modules", + "target", + "build", + "dist", + "out", + "bin", + "obj", + "*.o", + "*.so", + "*.dylib", + "*.dll", + "*.exe", + + // Logs and temporary files + "*.log", + "*.tmp", + "*.temp", + ".cache", + ".tmp", + + // Language-specific + "__pycache__", + "*.pyc", + "*.pyo", + ".pytest_cache", + "vendor", + "Cargo.lock", + "package-lock.json", + "yarn.lock", + "pnpm-lock.yaml", + + // OS generated files + ".Trash", + ".Spotlight-V100", + ".fseventsd", + + // Crush + ".crush", + ) +}) + +var homeIgnore = sync.OnceValue(func() ignore.IgnoreParser { + home := config.HomeDir() + var lines []string + for _, name := range []string{ + filepath.Join(home, ".gitignore"), + filepath.Join(home, ".config", "git", "ignore"), + filepath.Join(home, ".config", "crush", "ignore"), + } { + if bts, err := os.ReadFile(name); err == nil { + lines = append(lines, strings.Split(string(bts), "\n")...) + } + } + return ignore.CompileIgnoreLines(lines...) +}) -type DirectoryLister struct { - gitignore *ignore.GitIgnore - crushignore *ignore.GitIgnore - commonIgnore *ignore.GitIgnore - rootPath string +type directoryLister struct { + ignores *csync.Map[string, ignore.IgnoreParser] + rootPath string } -func NewDirectoryLister(rootPath string) *DirectoryLister { - dl := &DirectoryLister{ +func NewDirectoryLister(rootPath string) *directoryLister { + dl := &directoryLister{ rootPath: rootPath, + ignores: csync.NewMap[string, ignore.IgnoreParser](), } + dl.getIgnore(rootPath) + return dl +} - // Load gitignore if it exists - gitignorePath := filepath.Join(rootPath, ".gitignore") - if _, err := os.Stat(gitignorePath); err == nil { - if gi, err := ignore.CompileIgnoreFile(gitignorePath); err == nil { - dl.gitignore = gi - } - } - - // Load crushignore if it exists - crushignorePath := filepath.Join(rootPath, ".crushignore") - if _, err := os.Stat(crushignorePath); err == nil { - if ci, err := ignore.CompileIgnoreFile(crushignorePath); err == nil { - dl.crushignore = ci +// git checks, in order: +// - ./.gitignore, ../.gitignore, etc, until repo root +// ~/.config/git/ignore +// ~/.gitignore +// +// This will do the following: +// - the given ignorePatterns +// - [commonIgnorePatterns] +// - ./.gitignore, ../.gitignore, etc, until dl.rootPath +// - ./.crushignore, ../.crushignore, etc, until dl.rootPath +// ~/.config/git/ignore +// ~/.gitignore +// ~/.config/crush/ignore +func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bool { + if len(ignorePatterns) > 0 { + base := filepath.Base(path) + for _, pattern := range ignorePatterns { + if matched, err := filepath.Match(pattern, base); err == nil && matched { + return true + } } } - // Create common ignore patterns - dl.commonIgnore = ignore.CompileIgnoreLines(CommonIgnorePatterns...) - - return dl -} - -func (dl *DirectoryLister) shouldIgnore(path string, ignorePatterns []string) bool { relPath, err := filepath.Rel(dl.rootPath, path) if err != nil { relPath = path } - // Check common ignore patterns - if dl.commonIgnore.MatchesPath(relPath) { + if commonIgnorePatterns().MatchesPath(relPath) { + slog.Debug("ingoring common pattern", "path", relPath) return true } - // Check gitignore patterns if available - if dl.gitignore != nil && dl.gitignore.MatchesPath(relPath) { + if dl.getIgnore(filepath.Dir(path)).MatchesPath(relPath) { + slog.Debug("ingoring dir pattern", "path", relPath, "dir", filepath.Dir(path)) return true } - // Check crushignore patterns if available - if dl.crushignore != nil && dl.crushignore.MatchesPath(relPath) { + if dl.checkParentIgnores(relPath) { return true } - base := filepath.Base(path) + if homeIgnore().MatchesPath(relPath) { + slog.Debug("ingoring home dir pattern", "path", relPath) + return true + } + + return false +} - for _, pattern := range ignorePatterns { - matched, err := filepath.Match(pattern, base) - if err == nil && matched { +func (dl *directoryLister) checkParentIgnores(path string) bool { + parent := filepath.Dir(filepath.Dir(path)) + for parent != dl.rootPath && parent != "." && path != "." { + if dl.getIgnore(parent).MatchesPath(path) { + slog.Debug("ingoring parent dir pattern", "path", path, "dir", parent) return true } + parent = filepath.Dir(parent) } return false } +func (dl *directoryLister) getIgnore(path string) ignore.IgnoreParser { + return dl.ignores.GetOrSet(path, func() ignore.IgnoreParser { + var lines []string + for _, ign := range []string{".crushignore", ".gitignore"} { + name := filepath.Join(path, ign) + if content, err := os.ReadFile(name); err == nil { + lines = append(lines, strings.Split(string(content), "\n")...) + } + } + if len(lines) == 0 { + // Return a no-op parser to avoid nil checks + return ignore.CompileIgnoreLines() + } + return ignore.CompileIgnoreLines(lines...) + }) +} + // ListDirectory lists files and directories in the specified path, func ListDirectory(initialPath string, ignorePatterns []string, limit int) ([]string, bool, error) { var results []string @@ -144,6 +193,7 @@ func ListDirectory(initialPath string, ignorePatterns []string, limit int) ([]st ToSlash: fastwalk.DefaultToSlash(), Sort: fastwalk.SortDirsFirst, } + err := fastwalk.Walk(&conf, initialPath, func(path string, d os.DirEntry, err error) error { if err != nil { return nil // Skip files we don't have permission to access diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index 411a5a607e1323e022ca483e1d9c310115fb3e5f..e50f0b4f6de13a94c3017eabc708826b613399f3 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/internal/tui/components/chat/editor/editor.go @@ -473,7 +473,7 @@ func (m *editorCmp) SetPosition(x, y int) tea.Cmd { } func (m *editorCmp) startCompletions() tea.Msg { - files, _, _ := fsext.ListDirectory(".", []string{}, 0) + files, _, _ := fsext.ListDirectory(".", nil, 0) completionItems := make([]completions.Completion, 0, len(files)) for _, file := range files { file = strings.TrimPrefix(file, "./")