on main help sign in
Merge pull request #633 from charmbracelet/ignore
318cc22Kujtim Hoxha
internal/config/load.go+13−9
@@ -1,16 +1,19 @@
1package config
1package config
2
2
3import (
3import (
4+ "cmp"
4 "encoding/json"
5 "encoding/json"
5 "fmt"
6 "fmt"
6 "io"
7 "io"
7 "log/slog"
8 "log/slog"
8 "maps"
9 "maps"
9 "os"
10 "os"
11+ "os/user"
10 "path/filepath"
12 "path/filepath"
11 "runtime"
13 "runtime"
12 "slices"
14 "slices"
13 "strings"
15 "strings"
16+ "sync"
14
17
15 "github.com/charmbracelet/catwalk/pkg/catwalk"
18 "github.com/charmbracelet/catwalk/pkg/catwalk"
16 "github.com/charmbracelet/crush/internal/csync"
19 "github.com/charmbracelet/crush/internal/csync"
@@ -538,13 +541,14 @@ func GlobalConfigData() string {
538 return filepath.Join(os.Getenv("HOME"), ".local", "share", appName, fmt.Sprintf("%s.json", appName))
541 return filepath.Join(os.Getenv("HOME"), ".local", "share", appName, fmt.Sprintf("%s.json", appName))
539}
542}
540
543
541−func HomeDir() string {
544+var HomeDir = sync.OnceValue(func() string {
542− homeDir := os.Getenv("HOME")
545+ u, err := user.Current()
543− if homeDir == "" {
546+ if err == nil {
544− homeDir = os.Getenv("USERPROFILE") // For Windows compatibility
547+ return u.HomeDir
545 }
548 }
546− if homeDir == "" {
547− homeDir = os.Getenv("HOMEPATH") // Fallback for some environments
548− }
549− return homeDir
549+ return cmp.Or(
550+ os.Getenv("HOME"),
551+ os.Getenv("USERPROFILE"),
552+ os.Getenv("HOMEPATH"),
553+ )
550−}
554+})
internal/csync/maps.go+12
@@ -56,6 +56,18 @@ func (m *Map[K, V]) Len() int {
56 return len(m.inner)
56 return len(m.inner)
57}
57}
58
58
59+// GetOrSet gets and returns the key if it exists, otherwise, it executes the
60+// given function, set its return value for the given key, and returns it.
61+func (m *Map[K, V]) GetOrSet(key K, fn func() V) V {
62+ got, ok := m.Get(key)
63+ if ok {
64+ return got
65+ }
66+ value := fn()
67+ m.Set(key, value)
68+ return value
69+}
70+
59// Take gets an item and then deletes it.
71// Take gets an item and then deletes it.
60func (m *Map[K, V]) Take(key K) (V, bool) {
72func (m *Map[K, V]) Take(key K) (V, bool) {
61 m.mu.Lock()
73 m.mu.Lock()
internal/csync/maps_test.go+10
@@ -54,6 +54,16 @@ func TestMap_Set(t *testing.T) {
54 require.Equal(t, 1, m.Len())
54 require.Equal(t, 1, m.Len())
55}
55}
56
56
57+func TestMap_GetOrSet(t *testing.T) {
58+ t.Parallel()
59+
60+ m := NewMap[string, int]()
61+
62+ require.Equal(t, 42, m.GetOrSet("key1", func() int { return 42 }))
63+ require.Equal(t, 42, m.GetOrSet("key1", func() int { return 99999 }))
64+ require.Equal(t, 1, m.Len())
65+}
66+
57func TestMap_Get(t *testing.T) {
67func TestMap_Get(t *testing.T) {
58 t.Parallel()
68 t.Parallel()
59
69
internal/fsext/ignore_test.go+4−16
@@ -25,20 +25,8 @@ func TestCrushIgnore(t *testing.T) {
25 // Create a .crushignore file that ignores .log files
25 // Create a .crushignore file that ignores .log files
26 require.NoError(t, os.WriteFile(".crushignore", []byte("*.log\n"), 0o644))
26 require.NoError(t, os.WriteFile(".crushignore", []byte("*.log\n"), 0o644))
27
27
28− // Test DirectoryLister
29− t.Run("DirectoryLister respects .crushignore", func(t *testing.T) {
30− dl := NewDirectoryLister(tempDir)
28+ dl := NewDirectoryLister(tempDir)
31−
32− // Test that .log files are ignored
33− require.True(t, dl.gitignore == nil, "gitignore should be nil")
29+ require.True(t, dl.shouldIgnore("test2.log", nil), ".log files should be ignored")
34− require.NotNil(t, dl.crushignore, "crushignore should not be nil")
30+ require.False(t, dl.shouldIgnore("test1.txt", nil), ".txt files should not be ignored")
35− })
31+ require.True(t, dl.shouldIgnore("test3.tmp", nil), ".tmp files should be ignored by common patterns")
36−
37− // Test FastGlobWalker
38− t.Run("FastGlobWalker respects .crushignore", func(t *testing.T) {
39− walker := NewFastGlobWalker(tempDir)
40−
41− require.True(t, walker.gitignore == nil, "gitignore should be nil")
42− require.NotNil(t, walker.crushignore, "crushignore should not be nil")
43− })
44}
32}
internal/fsext/ls.go+144−94
@@ -1,137 +1,186 @@
1package fsext
1package fsext
2
2
3import (
3import (
4+ "log/slog"
4 "os"
5 "os"
5 "path/filepath"
6 "path/filepath"
7+ "strings"
8+ "sync"
6
9
7 "github.com/charlievieth/fastwalk"
10 "github.com/charlievieth/fastwalk"
11+ "github.com/charmbracelet/crush/internal/config"
12+ "github.com/charmbracelet/crush/internal/csync"
8 ignore "github.com/sabhiram/go-gitignore"
13 ignore "github.com/sabhiram/go-gitignore"
9)
14)
10
15
11−// CommonIgnorePatterns contains commonly ignored files and directories
16+// commonIgnorePatterns contains commonly ignored files and directories
12−var CommonIgnorePatterns = []string{
17+var commonIgnorePatterns = sync.OnceValue(func() ignore.IgnoreParser {
18+ return ignore.CompileIgnoreLines(
13− // Version control
19+ // Version control
14− ".git",
20+ ".git",
15− ".svn",
21+ ".svn",
16− ".hg",
22+ ".hg",
17− ".bzr",
23+ ".bzr",
18−
24+
19− // IDE and editor files
25+ // IDE and editor files
20− ".vscode",
26+ ".vscode",
21− ".idea",
27+ ".idea",
22− "*.swp",
28+ "*.swp",
23− "*.swo",
29+ "*.swo",
24− "*~",
30+ "*~",
25− ".DS_Store",
31+ ".DS_Store",
26− "Thumbs.db",
32+ "Thumbs.db",
27−
33+
28− // Build artifacts and dependencies
34+ // Build artifacts and dependencies
29− "node_modules",
35+ "node_modules",
30− "target",
36+ "target",
31− "build",
37+ "build",
32− "dist",
38+ "dist",
33− "out",
39+ "out",
34− "bin",
40+ "bin",
35− "obj",
41+ "obj",
36− "*.o",
42+ "*.o",
37− "*.so",
43+ "*.so",
38− "*.dylib",
44+ "*.dylib",
39− "*.dll",
45+ "*.dll",
40− "*.exe",
46+ "*.exe",
41−
47+
42− // Logs and temporary files
48+ // Logs and temporary files
43− "*.log",
49+ "*.log",
44− "*.tmp",
50+ "*.tmp",
45− "*.temp",
51+ "*.temp",
46− ".cache",
52+ ".cache",
47− ".tmp",
53+ ".tmp",
48−
54+
49− // Language-specific
55+ // Language-specific
50− "__pycache__",
56+ "__pycache__",
51− "*.pyc",
57+ "*.pyc",
52− "*.pyo",
58+ "*.pyo",
53− ".pytest_cache",
59+ ".pytest_cache",
54− "vendor",
60+ "vendor",
55− "Cargo.lock",
61+ "Cargo.lock",
56− "package-lock.json",
62+ "package-lock.json",
57− "yarn.lock",
63+ "yarn.lock",
58− "pnpm-lock.yaml",
64+ "pnpm-lock.yaml",
59−
65+
60− // OS generated files
66+ // OS generated files
61− ".Trash",
67+ ".Trash",
62− ".Spotlight-V100",
68+ ".Spotlight-V100",
63− ".fseventsd",
69+ ".fseventsd",
64−
70+
65− // Crush
71+ // Crush
66− ".crush",
72+ ".crush",
73+ )
74+})
75+
76+var homeIgnore = sync.OnceValue(func() ignore.IgnoreParser {
77+ home := config.HomeDir()
78+ var lines []string
79+ for _, name := range []string{
80+ filepath.Join(home, ".gitignore"),
81+ filepath.Join(home, ".config", "git", "ignore"),
82+ filepath.Join(home, ".config", "crush", "ignore"),
67−}
83+ } {
84+ if bts, err := os.ReadFile(name); err == nil {
85+ lines = append(lines, strings.Split(string(bts), "\n")...)
86+ }
87+ }
88+ return ignore.CompileIgnoreLines(lines...)
89+})
68
90
69−type DirectoryLister struct {
91+type directoryLister struct {
70− gitignore *ignore.GitIgnore
92+ ignores *csync.Map[string, ignore.IgnoreParser]
71− crushignore *ignore.GitIgnore
72− commonIgnore *ignore.GitIgnore
73− rootPath string
93+ rootPath string
74}
94}
75
95
76−func NewDirectoryLister(rootPath string) *DirectoryLister {
96+func NewDirectoryLister(rootPath string) *directoryLister {
77− dl := &DirectoryLister{
97+ dl := &directoryLister{
78 rootPath: rootPath,
98 rootPath: rootPath,
99+ ignores: csync.NewMap[string, ignore.IgnoreParser](),
79 }
100 }
101+ dl.getIgnore(rootPath)
102+ return dl
103+}
80
104
81− // Load gitignore if it exists
105+// git checks, in order:
82− gitignorePath := filepath.Join(rootPath, ".gitignore")
106+// - ./.gitignore, ../.gitignore, etc, until repo root
83− if _, err := os.Stat(gitignorePath); err == nil {
107+// ~/.config/git/ignore
84− if gi, err := ignore.CompileIgnoreFile(gitignorePath); err == nil {
108+// ~/.gitignore
85− dl.gitignore = gi
109+//
86− }
110+// This will do the following:
87− }
111+// - the given ignorePatterns
88−
112+// - [commonIgnorePatterns]
89− // Load crushignore if it exists
113+// - ./.gitignore, ../.gitignore, etc, until dl.rootPath
90− crushignorePath := filepath.Join(rootPath, ".crushignore")
114+// - ./.crushignore, ../.crushignore, etc, until dl.rootPath
91− if _, err := os.Stat(crushignorePath); err == nil {
115+// ~/.config/git/ignore
92− if ci, err := ignore.CompileIgnoreFile(crushignorePath); err == nil {
116+// ~/.gitignore
93− dl.crushignore = ci
117+// ~/.config/crush/ignore
118+func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bool {
119+ if len(ignorePatterns) > 0 {
120+ base := filepath.Base(path)
121+ for _, pattern := range ignorePatterns {
122+ if matched, err := filepath.Match(pattern, base); err == nil && matched {
123+ return true
124+ }
94 }
125 }
95 }
126 }
96
127
97− // Create common ignore patterns
98− dl.commonIgnore = ignore.CompileIgnoreLines(CommonIgnorePatterns...)
99−
100− return dl
101−}
102−
103−func (dl *DirectoryLister) shouldIgnore(path string, ignorePatterns []string) bool {
104 relPath, err := filepath.Rel(dl.rootPath, path)
128 relPath, err := filepath.Rel(dl.rootPath, path)
105 if err != nil {
129 if err != nil {
106 relPath = path
130 relPath = path
107 }
131 }
108
132
109− // Check common ignore patterns
110− if dl.commonIgnore.MatchesPath(relPath) {
133+ if commonIgnorePatterns().MatchesPath(relPath) {
134+ slog.Debug("ingoring common pattern", "path", relPath)
111 return true
135 return true
112 }
136 }
113
137
114− // Check gitignore patterns if available
115− if dl.gitignore != nil && dl.gitignore.MatchesPath(relPath) {
138+ if dl.getIgnore(filepath.Dir(path)).MatchesPath(relPath) {
139+ slog.Debug("ingoring dir pattern", "path", relPath, "dir", filepath.Dir(path))
116 return true
140 return true
117 }
141 }
118
142
119− // Check crushignore patterns if available
143+ if dl.checkParentIgnores(relPath) {
120− if dl.crushignore != nil && dl.crushignore.MatchesPath(relPath) {
121 return true
144 return true
122 }
145 }
123
146
124− base := filepath.Base(path)
147+ if homeIgnore().MatchesPath(relPath) {
148+ slog.Debug("ingoring home dir pattern", "path", relPath)
149+ return true
150+ }
151+
152+ return false
153+}
125
154
126− for _, pattern := range ignorePatterns {
155+func (dl *directoryLister) checkParentIgnores(path string) bool {
127− matched, err := filepath.Match(pattern, base)
156+ parent := filepath.Dir(filepath.Dir(path))
128− if err == nil && matched {
157+ for parent != dl.rootPath && parent != "." && path != "." {
158+ if dl.getIgnore(parent).MatchesPath(path) {
159+ slog.Debug("ingoring parent dir pattern", "path", path, "dir", parent)
129 return true
160 return true
130 }
161 }
162+ parent = filepath.Dir(parent)
131 }
163 }
132 return false
164 return false
133}
165}
134
166
167+func (dl *directoryLister) getIgnore(path string) ignore.IgnoreParser {
168+ return dl.ignores.GetOrSet(path, func() ignore.IgnoreParser {
169+ var lines []string
170+ for _, ign := range []string{".crushignore", ".gitignore"} {
171+ name := filepath.Join(path, ign)
172+ if content, err := os.ReadFile(name); err == nil {
173+ lines = append(lines, strings.Split(string(content), "\n")...)
174+ }
175+ }
176+ if len(lines) == 0 {
177+ // Return a no-op parser to avoid nil checks
178+ return ignore.CompileIgnoreLines()
179+ }
180+ return ignore.CompileIgnoreLines(lines...)
181+ })
182+}
183+
135// ListDirectory lists files and directories in the specified path,
184// ListDirectory lists files and directories in the specified path,
136func ListDirectory(initialPath string, ignorePatterns []string, limit int) ([]string, bool, error) {
185func ListDirectory(initialPath string, ignorePatterns []string, limit int) ([]string, bool, error) {
137 var results []string
186 var results []string
@@ -144,6 +193,7 @@ func ListDirectory(initialPath string, ignorePatterns []string, limit int) ([]st
144 ToSlash: fastwalk.DefaultToSlash(),
193 ToSlash: fastwalk.DefaultToSlash(),
145 Sort: fastwalk.SortDirsFirst,
194 Sort: fastwalk.SortDirsFirst,
146 }
195 }
196+
147 err := fastwalk.Walk(&conf, initialPath, func(path string, d os.DirEntry, err error) error {
197 err := fastwalk.Walk(&conf, initialPath, func(path string, d os.DirEntry, err error) error {
148 if err != nil {
198 if err != nil {
149 return nil // Skip files we don't have permission to access
199 return nil // Skip files we don't have permission to access
internal/tui/components/chat/editor/editor.go+1−1
@@ -473,7 +473,7 @@ func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
473}
473}
474
474
475func (m *editorCmp) startCompletions() tea.Msg {
475func (m *editorCmp) startCompletions() tea.Msg {
476− files, _, _ := fsext.ListDirectory(".", []string{}, 0)
476+ files, _, _ := fsext.ListDirectory(".", nil, 0)
477 completionItems := make([]completions.Completion, 0, len(files))
477 completionItems := make([]completions.Completion, 0, len(files))
478 for _, file := range files {
478 for _, file := range files {
479 file = strings.TrimPrefix(file, "./")
479 file = strings.TrimPrefix(file, "./")