From 84213b17e172e2a073a82c28f6e60bc33daeb3fe Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 7 Aug 2025 12:09:41 -0300 Subject: [PATCH 1/5] fix: improve ignore Signed-off-by: Carlos Alexandro Becker --- internal/fsext/ls.go | 61 +++++++++---------- internal/tui/components/chat/editor/editor.go | 2 +- 2 files changed, 29 insertions(+), 34 deletions(-) diff --git a/internal/fsext/ls.go b/internal/fsext/ls.go index e800e921de74e41a9d46f4d1fafd6fc59bcf65a0..5bd45dc4da20bacd805a4f0165d9701e5d5acc6e 100644 --- a/internal/fsext/ls.go +++ b/internal/fsext/ls.go @@ -1,8 +1,12 @@ package fsext import ( + "bytes" + "io" + "log/slog" "os" "path/filepath" + "strings" "github.com/charlievieth/fastwalk" ignore "github.com/sabhiram/go-gitignore" @@ -67,10 +71,8 @@ var CommonIgnorePatterns = []string{ } type DirectoryLister struct { - gitignore *ignore.GitIgnore - crushignore *ignore.GitIgnore - commonIgnore *ignore.GitIgnore - rootPath string + ignores *ignore.GitIgnore + rootPath string } func NewDirectoryLister(rootPath string) *DirectoryLister { @@ -78,26 +80,29 @@ func NewDirectoryLister(rootPath string) *DirectoryLister { rootPath: rootPath, } - // 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 - } - } + dl.ignores = ignore.CompileIgnoreLines(append(CommonIgnorePatterns, strings.Split(parseIgnores(rootPath), "\n")...)...) + + return dl +} - // 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 +func parseIgnores(path string) string { + var b bytes.Buffer + for _, ign := range []string{".crushignore", ".gitignore"} { + p := filepath.Join(path, ign) + if _, err := os.Stat(p); err == nil { + f, err := os.Open(p) + if err != nil { + _ = f.Close() + slog.Error("Failed to open ignore file", "path", p, "error", err) + continue + } + if _, err := io.Copy(&b, f); err != nil { + slog.Error("Failed to read ignore file", "path", p, "error", err) + } + _ = f.Close() } } - - // Create common ignore patterns - dl.commonIgnore = ignore.CompileIgnoreLines(CommonIgnorePatterns...) - - return dl + return b.String() } func (dl *DirectoryLister) shouldIgnore(path string, ignorePatterns []string) bool { @@ -106,18 +111,7 @@ func (dl *DirectoryLister) shouldIgnore(path string, ignorePatterns []string) bo relPath = path } - // Check common ignore patterns - if dl.commonIgnore.MatchesPath(relPath) { - return true - } - - // Check gitignore patterns if available - if dl.gitignore != nil && dl.gitignore.MatchesPath(relPath) { - return true - } - - // Check crushignore patterns if available - if dl.crushignore != nil && dl.crushignore.MatchesPath(relPath) { + if dl.ignores.MatchesPath(relPath) { return true } @@ -144,6 +138,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 4c7df84daad5b911be66dcd7f7cf6d832d714293..52884847ba65d96c24a939b227061fd4cc556f1a 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, "./") From 9719451a3d6fb3483ef94757923b7340470e4410 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 7 Aug 2025 14:00:34 -0300 Subject: [PATCH 2/5] fix: config.HomeDir sync.OnceValue, use user.Current().HomeDir Signed-off-by: Carlos Alexandro Becker --- internal/config/load.go | 22 +++++----- internal/fsext/ls.go | 89 +++++++++++++++++++++++------------------ 2 files changed, 64 insertions(+), 47 deletions(-) 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/fsext/ls.go b/internal/fsext/ls.go index 5bd45dc4da20bacd805a4f0165d9701e5d5acc6e..d106e7cf106caa906f7d95f05c60f477fc078cbd 100644 --- a/internal/fsext/ls.go +++ b/internal/fsext/ls.go @@ -9,11 +9,12 @@ import ( "strings" "github.com/charlievieth/fastwalk" + "github.com/charmbracelet/crush/internal/csync" ignore "github.com/sabhiram/go-gitignore" ) -// CommonIgnorePatterns contains commonly ignored files and directories -var CommonIgnorePatterns = []string{ +// commonIgnorePatterns contains commonly ignored files and directories +var commonIgnorePatterns = ignore.CompileIgnoreLines( // Version control ".git", ".svn", @@ -68,62 +69,74 @@ var CommonIgnorePatterns = []string{ // Crush ".crush", -} +) -type DirectoryLister struct { - ignores *ignore.GitIgnore +type directoryLister struct { + ignores *csync.Map[string, ignore.IgnoreParser] rootPath string } -func NewDirectoryLister(rootPath string) *DirectoryLister { - dl := &DirectoryLister{ +func NewDirectoryLister(rootPath string) *directoryLister { + return &directoryLister{ rootPath: rootPath, + ignores: csync.NewMap[string, ignore.IgnoreParser](), } - - dl.ignores = ignore.CompileIgnoreLines(append(CommonIgnorePatterns, strings.Split(parseIgnores(rootPath), "\n")...)...) - - return dl } -func parseIgnores(path string) string { - var b bytes.Buffer - for _, ign := range []string{".crushignore", ".gitignore"} { - p := filepath.Join(path, ign) - if _, err := os.Stat(p); err == nil { - f, err := os.Open(p) - if err != nil { - _ = f.Close() - slog.Error("Failed to open ignore file", "path", p, "error", err) - continue - } - if _, err := io.Copy(&b, f); err != nil { - slog.Error("Failed to read ignore file", "path", p, "error", err) - } - _ = f.Close() - } - } - return b.String() -} - -func (dl *DirectoryLister) shouldIgnore(path string, ignorePatterns []string) bool { +func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bool { relPath, err := filepath.Rel(dl.rootPath, path) if err != nil { relPath = path } - if dl.ignores.MatchesPath(relPath) { - return true - } - base := filepath.Base(path) - for _, pattern := range ignorePatterns { matched, err := filepath.Match(pattern, base) if err == nil && matched { + slog.Info("ignoring path", "path", path) return true } } - return false + + if commonIgnorePatterns.MatchesPath(relPath) || dl.getIgnore(path).MatchesPath(relPath) { + slog.Info("ignoring path", "path", path) + return true + } + + parent := filepath.Dir(path) + for { + if dl.getIgnore(parent).MatchesPath(path) { + slog.Info("ignoring path", "path", path, "parent", parent) + return true + } + if parent == "/" || parent == "." { // TODO: windows + return false + } + parent = filepath.Dir(parent) + } +} + +func (dl *directoryLister) getIgnore(path string) ignore.IgnoreParser { + return dl.ignores.GetOrSet(path, func() ignore.IgnoreParser { + var b bytes.Buffer + for _, ign := range []string{".crushignore", ".gitignore"} { + p := filepath.Join(path, ign) + if _, err := os.Stat(p); err == nil { + slog.Info("loading ignore file", "path", p) + f, err := os.Open(p) + if err != nil { + _ = f.Close() + slog.Error("Failed to open ignore file", "path", p, "error", err) + continue + } + if _, err := io.Copy(&b, f); err != nil { + slog.Error("Failed to read ignore file", "path", p, "error", err) + } + _ = f.Close() + } + } + return ignore.CompileIgnoreLines(strings.Split(b.String(), "\n")...) + }) } // ListDirectory lists files and directories in the specified path, From 1e47f4eda8772536b63ef866209e65b6b81cae37 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 7 Aug 2025 14:01:12 -0300 Subject: [PATCH 3/5] feat(csync.Map): added GetOrSet Signed-off-by: Carlos Alexandro Becker --- internal/csync/maps.go | 12 ++++++++++++ internal/csync/maps_test.go | 10 ++++++++++ 2 files changed, 22 insertions(+) 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() From fbe879d4b56d1f95fd02b36b986def6afe8eb0f8 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 7 Aug 2025 14:01:36 -0300 Subject: [PATCH 4/5] feat: improve .crushignore and .gitignore Signed-off-by: Carlos Alexandro Becker --- internal/fsext/ignore_test.go | 20 ++----- internal/fsext/ls.go | 104 +++++++++++++++++++++++----------- 2 files changed, 75 insertions(+), 49 deletions(-) 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 d106e7cf106caa906f7d95f05c60f477fc078cbd..d5fd50f5c283d70ec148109b1bdef5f753cbdc5b 100644 --- a/internal/fsext/ls.go +++ b/internal/fsext/ls.go @@ -1,14 +1,13 @@ package fsext import ( - "bytes" - "io" "log/slog" "os" "path/filepath" "strings" "github.com/charlievieth/fastwalk" + "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/csync" ignore "github.com/sabhiram/go-gitignore" ) @@ -77,65 +76,104 @@ type directoryLister struct { } func NewDirectoryLister(rootPath string) *directoryLister { - return &directoryLister{ + dl := &directoryLister{ rootPath: rootPath, ignores: csync.NewMap[string, ignore.IgnoreParser](), } + dl.getIgnore(rootPath) + dl.ignores.GetOrSet("~", 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...) + }) + return dl } +// 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 + } + } + } + relPath, err := filepath.Rel(dl.rootPath, path) if err != nil { relPath = path } - base := filepath.Base(path) - for _, pattern := range ignorePatterns { - matched, err := filepath.Match(pattern, base) - if err == nil && matched { - slog.Info("ignoring path", "path", path) - return true - } + if commonIgnorePatterns.MatchesPath(relPath) { + slog.Debug("ingoring common pattern", "path", relPath) + return true } - if commonIgnorePatterns.MatchesPath(relPath) || dl.getIgnore(path).MatchesPath(relPath) { - slog.Info("ignoring path", "path", path) + if dl.getIgnore(filepath.Dir(path)).MatchesPath(relPath) { + slog.Debug("ingoring dir pattern", "path", relPath, "dir", filepath.Dir(path)) return true } - parent := filepath.Dir(path) - for { + if dl.checkParentIgnores(relPath) { + return true + } + + if dl.getIgnore("~").MatchesPath(relPath) { + slog.Debug("ingoring home dir pattern", "path", relPath) + return true + } + + return false +} + +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.Info("ignoring path", "path", path, "parent", parent) + slog.Debug("ingoring parent dir pattern", "path", path, "dir", parent) return true } - if parent == "/" || parent == "." { // TODO: windows - return false - } parent = filepath.Dir(parent) } + return false } func (dl *directoryLister) getIgnore(path string) ignore.IgnoreParser { return dl.ignores.GetOrSet(path, func() ignore.IgnoreParser { - var b bytes.Buffer + var lines []string for _, ign := range []string{".crushignore", ".gitignore"} { - p := filepath.Join(path, ign) - if _, err := os.Stat(p); err == nil { - slog.Info("loading ignore file", "path", p) - f, err := os.Open(p) - if err != nil { - _ = f.Close() - slog.Error("Failed to open ignore file", "path", p, "error", err) - continue - } - if _, err := io.Copy(&b, f); err != nil { - slog.Error("Failed to read ignore file", "path", p, "error", err) - } - _ = f.Close() + name := filepath.Join(path, ign) + if content, err := os.ReadFile(name); err == nil { + lines = append(lines, strings.Split(string(content), "\n")...) } } - return ignore.CompileIgnoreLines(strings.Split(b.String(), "\n")...) + if len(lines) == 0 { + // Return a no-op parser to avoid nil checks + return ignore.CompileIgnoreLines() + } + return ignore.CompileIgnoreLines(lines...) }) } From a05b4342f64130c25feddf7234a1da7666bf0ac5 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 7 Aug 2025 14:06:23 -0300 Subject: [PATCH 5/5] perf: make it a tad faster Signed-off-by: Carlos Alexandro Becker --- internal/fsext/ls.go | 148 ++++++++++++++++++++++--------------------- 1 file changed, 76 insertions(+), 72 deletions(-) diff --git a/internal/fsext/ls.go b/internal/fsext/ls.go index d5fd50f5c283d70ec148109b1bdef5f753cbdc5b..e691c148ca880a17aed410f072fa1cb3078fc2b4 100644 --- a/internal/fsext/ls.go +++ b/internal/fsext/ls.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "strings" + "sync" "github.com/charlievieth/fastwalk" "github.com/charmbracelet/crush/internal/config" @@ -13,62 +14,79 @@ import ( ) // commonIgnorePatterns contains commonly ignored files and directories -var commonIgnorePatterns = 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 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 { ignores *csync.Map[string, ignore.IgnoreParser] @@ -81,20 +99,6 @@ func NewDirectoryLister(rootPath string) *directoryLister { ignores: csync.NewMap[string, ignore.IgnoreParser](), } dl.getIgnore(rootPath) - dl.ignores.GetOrSet("~", 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...) - }) return dl } @@ -126,7 +130,7 @@ func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bo relPath = path } - if commonIgnorePatterns.MatchesPath(relPath) { + if commonIgnorePatterns().MatchesPath(relPath) { slog.Debug("ingoring common pattern", "path", relPath) return true } @@ -140,7 +144,7 @@ func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bo return true } - if dl.getIgnore("~").MatchesPath(relPath) { + if homeIgnore().MatchesPath(relPath) { slog.Debug("ingoring home dir pattern", "path", relPath) return true }