on main help sign in
feat: limit filepath walk, automatic low limits when not git repo (#1052)
c96abaeCarlos Alexandro Becker
internal/config/config.go+35
@@ -131,6 +131,19 @@ type TUIOptions struct {
131 CompactMode bool `json:"compact_mode,omitempty" jsonschema:"description=Enable compact mode for the TUI interface,default=false"`
131 CompactMode bool `json:"compact_mode,omitempty" jsonschema:"description=Enable compact mode for the TUI interface,default=false"`
132 DiffMode string `json:"diff_mode,omitempty" jsonschema:"description=Diff mode for the TUI interface,enum=unified,enum=split"`
132 DiffMode string `json:"diff_mode,omitempty" jsonschema:"description=Diff mode for the TUI interface,enum=unified,enum=split"`
133 // Here we can add themes later or any TUI related options
133 // Here we can add themes later or any TUI related options
134+ //
135+
136+ Completions Completions `json:"completions,omitzero" jsonschema:"description=Completions UI options"`
137+}
138+
139+// Completions defines options for the completions UI.
140+type Completions struct {
141+ MaxDepth *int `json:"max_depth,omitempty" jsonschema:"description=Maximum depth for the ls tool,default=0,example=10"`
142+ MaxItems *int `json:"max_items,omitempty" jsonschema:"description=Maximum number of items to return for the ls tool,default=1000,example=100"`
143+}
144+
145+func (c Completions) Limits() (depth, items int) {
146+ return ptrValOr(c.MaxDepth, -1), ptrValOr(c.MaxItems, -1)
134}
147}
135
148
136type Permissions struct {
149type Permissions struct {
@@ -246,6 +259,19 @@ type Agent struct {
246 ContextPaths []string `json:"context_paths,omitempty"`
259 ContextPaths []string `json:"context_paths,omitempty"`
247}
260}
248
261
262+type Tools struct {
263+ Ls ToolLs `json:"ls,omitzero"`
264+}
265+
266+type ToolLs struct {
267+ MaxDepth *int `json:"max_depth,omitempty" jsonschema:"description=Maximum depth for the ls tool,default=0,example=10"`
268+ MaxItems *int `json:"max_items,omitempty" jsonschema:"description=Maximum number of items to return for the ls tool,default=1000,example=100"`
269+}
270+
271+func (t ToolLs) Limits() (depth, items int) {
272+ return ptrValOr(t.MaxDepth, -1), ptrValOr(t.MaxItems, -1)
273+}
274+
249// Config holds the configuration for crush.
275// Config holds the configuration for crush.
250type Config struct {
276type Config struct {
251 Schema string `json:"$schema,omitempty"`
277 Schema string `json:"$schema,omitempty"`
@@ -264,6 +290,8 @@ type Config struct {
264
290
265 Permissions *Permissions `json:"permissions,omitempty" jsonschema:"description=Permission settings for tool usage"`
291 Permissions *Permissions `json:"permissions,omitempty" jsonschema:"description=Permission settings for tool usage"`
266
292
293+ Tools Tools `json:"tools,omitzero" jsonschema:"description=Tool configurations"`
294+
267 // Internal
295 // Internal
268 workingDir string `json:"-"`
296 workingDir string `json:"-"`
269 // TODO: most likely remove this concept when I come back to it
297 // TODO: most likely remove this concept when I come back to it
@@ -579,3 +607,10 @@ func resolveEnvs(envs map[string]string) []string {
579 }
607 }
580 return res
608 return res
581}
609}
610+
611+func ptrValOr[T any](t *T, el T) T {
612+ if t == nil {
613+ return el
614+ }
615+ return *t
616+}
internal/config/load.go+27
@@ -1,12 +1,14 @@
1package config
1package config
2
2
3import (
3import (
4+ "context"
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/exec"
10 "path/filepath"
12 "path/filepath"
11 "runtime"
13 "runtime"
12 "slices"
14 "slices"
@@ -62,6 +64,16 @@ func Load(workingDir, dataDir string, debug bool) (*Config, error) {
62 cfg.Options.Debug,
64 cfg.Options.Debug,
63 )
65 )
64
66
67+ if !isInsideWorktree() {
68+ const depth = 2
69+ const items = 100
70+ slog.Warn("No git repository detected in working directory, will limit file walk operations", "depth", depth, "items", items)
71+ assignIfNil(&cfg.Tools.Ls.MaxDepth, depth)
72+ assignIfNil(&cfg.Tools.Ls.MaxItems, items)
73+ assignIfNil(&cfg.Options.TUI.Completions.MaxDepth, depth)
74+ assignIfNil(&cfg.Options.TUI.Completions.MaxItems, items)
75+ }
76+
65 // Load known providers, this loads the config from catwalk
77 // Load known providers, this loads the config from catwalk
66 providers, err := Providers(cfg)
78 providers, err := Providers(cfg)
67 if err != nil {
79 if err != nil {
@@ -638,3 +650,18 @@ func GlobalConfigData() string {
638
650
639 return filepath.Join(home.Dir(), ".local", "share", appName, fmt.Sprintf("%s.json", appName))
651 return filepath.Join(home.Dir(), ".local", "share", appName, fmt.Sprintf("%s.json", appName))
640}
652}
653+
654+func assignIfNil[T any](ptr **T, val T) {
655+ if *ptr == nil {
656+ *ptr = &val
657+ }
658+}
659+
660+func isInsideWorktree() bool {
661+ bts, err := exec.CommandContext(
662+ context.Background(),
663+ "git", "rev-parse",
664+ "--is-inside-work-tree",
665+ ).CombinedOutput()
666+ return err == nil && strings.TrimSpace(string(bts)) == "true"
667+}
internal/fsext/fileutil.go+19−46
@@ -1,15 +1,17 @@
1package fsext
1package fsext
2
2
3import (
3import (
4+ "errors"
4 "fmt"
5 "fmt"
5 "os"
6 "os"
6 "path/filepath"
7 "path/filepath"
7− "sort"
8+ "slices"
8 "strings"
9 "strings"
9 "time"
10 "time"
10
11
11 "github.com/bmatcuk/doublestar/v4"
12 "github.com/bmatcuk/doublestar/v4"
12 "github.com/charlievieth/fastwalk"
13 "github.com/charlievieth/fastwalk"
14+ "github.com/charmbracelet/crush/internal/csync"
13 "github.com/charmbracelet/crush/internal/home"
15 "github.com/charmbracelet/crush/internal/home"
14)
16)
15
17
@@ -80,10 +82,9 @@ func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool,
80 pattern = filepath.ToSlash(pattern)
82 pattern = filepath.ToSlash(pattern)
81
83
82 walker := NewFastGlobWalker(searchPath)
84 walker := NewFastGlobWalker(searchPath)
83− var matches []FileInfo
85+ found := csync.NewSlice[FileInfo]()
84 conf := fastwalk.Config{
86 conf := fastwalk.Config{
85− Follow: true,
87+ Follow: true,
86− // Use forward slashes when running a Windows binary under WSL or MSYS
87 ToSlash: fastwalk.DefaultToSlash(),
88 ToSlash: fastwalk.DefaultToSlash(),
88 Sort: fastwalk.SortFilesFirst,
89 Sort: fastwalk.SortFilesFirst,
89 }
90 }
@@ -121,31 +122,26 @@ func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool,
121 return nil
122 return nil
122 }
123 }
123
124
124− matches = append(matches, FileInfo{Path: path, ModTime: info.ModTime()})
125+ found.Append(FileInfo{Path: path, ModTime: info.ModTime()})
125− if limit > 0 && len(matches) >= limit*2 {
126+ if limit > 0 && found.Len() >= limit*2 { // NOTE: why x2?
126 return filepath.SkipAll
127 return filepath.SkipAll
127 }
128 }
128 return nil
129 return nil
129 })
130 })
130− if err != nil {
131+ if err != nil && !errors.Is(err, filepath.SkipAll) {
131 return nil, false, fmt.Errorf("fastwalk error: %w", err)
132 return nil, false, fmt.Errorf("fastwalk error: %w", err)
132 }
133 }
133
134
134− sort.Slice(matches, func(i, j int) bool {
135+ matches := slices.SortedFunc(found.Seq(), func(a, b FileInfo) int {
135− return matches[i].ModTime.After(matches[j].ModTime)
136+ return b.ModTime.Compare(a.ModTime)
136 })
137 })
137−
138+ matches, truncated := truncate(matches, limit)
138− truncated := false
139− if limit > 0 && len(matches) > limit {
140− matches = matches[:limit]
141− truncated = true
142− }
143
139
144 results := make([]string, len(matches))
140 results := make([]string, len(matches))
145 for i, m := range matches {
141 for i, m := range matches {
146 results[i] = m.Path
142 results[i] = m.Path
147 }
143 }
148− return results, truncated, nil
144+ return results, truncated || errors.Is(err, filepath.SkipAll), nil
149}
145}
150
146
151// ShouldExcludeFile checks if a file should be excluded from processing
147// ShouldExcludeFile checks if a file should be excluded from processing
@@ -155,36 +151,6 @@ func ShouldExcludeFile(rootPath, filePath string) bool {
155 shouldIgnore(filePath, nil)
151 shouldIgnore(filePath, nil)
156}
152}
157
153
158−// WalkDirectories walks a directory tree and calls the provided function for each directory,
159−// respecting hierarchical .gitignore/.crushignore files like git does.
160−func WalkDirectories(rootPath string, fn func(path string, d os.DirEntry, err error) error) error {
161− dl := NewDirectoryLister(rootPath)
162−
163− conf := fastwalk.Config{
164− Follow: true,
165− ToSlash: fastwalk.DefaultToSlash(),
166− Sort: fastwalk.SortDirsFirst,
167− }
168−
169− return fastwalk.Walk(&conf, rootPath, func(path string, d os.DirEntry, err error) error {
170− if err != nil {
171− return fn(path, d, err)
172− }
173−
174− // Only process directories
175− if !d.IsDir() {
176− return nil
177− }
178−
179− // Check if directory should be ignored
180− if dl.shouldIgnore(path, nil) {
181− return filepath.SkipDir
182− }
183−
184− return fn(path, d, err)
185− })
186−}
187−
188func PrettyPath(path string) string {
154func PrettyPath(path string) string {
189 return home.Short(path)
155 return home.Short(path)
190}
156}
@@ -248,3 +214,10 @@ func ToWindowsLineEndings(content string) (string, bool) {
248 }
214 }
249 return content, false
215 return content, false
250}
216}
217+
218+func truncate[T any](input []T, limit int) ([]T, bool) {
219+ if limit > 0 && len(input) > limit {
220+ return input[:limit], true
221+ }
222+ return input, false
223+}
internal/fsext/fileutil_test.go+43−47
@@ -5,7 +5,6 @@ import (
5 "os"
5 "os"
6 "path/filepath"
6 "path/filepath"
7 "testing"
7 "testing"
8− "testing/synctest"
9 "time"
8 "time"
10
9
11 "github.com/stretchr/testify/require"
10 "github.com/stretchr/testify/require"
@@ -148,37 +147,35 @@ func TestGlobWithDoubleStar(t *testing.T) {
148 require.NoError(t, err)
147 require.NoError(t, err)
149 require.False(t, truncated)
148 require.False(t, truncated)
150
149
151− require.Equal(t, matches, []string{file1})
150+ require.Equal(t, []string{file1}, matches)
152 })
151 })
153
152
154 t.Run("returns results sorted by modification time (newest first)", func(t *testing.T) {
153 t.Run("returns results sorted by modification time (newest first)", func(t *testing.T) {
155− synctest.Test(t, func(t *testing.T) {
156− testDir := t.TempDir()
154+ testDir := t.TempDir()
157
155
158− file1 := filepath.Join(testDir, "file1.txt")
156+ file1 := filepath.Join(testDir, "file1.txt")
159− require.NoError(t, os.WriteFile(file1, []byte("first"), 0o644))
157+ require.NoError(t, os.WriteFile(file1, []byte("first"), 0o644))
160
158
161− file2 := filepath.Join(testDir, "file2.txt")
159+ file2 := filepath.Join(testDir, "file2.txt")
162− require.NoError(t, os.WriteFile(file2, []byte("second"), 0o644))
160+ require.NoError(t, os.WriteFile(file2, []byte("second"), 0o644))
163
161
164− file3 := filepath.Join(testDir, "file3.txt")
162+ file3 := filepath.Join(testDir, "file3.txt")
165− require.NoError(t, os.WriteFile(file3, []byte("third"), 0o644))
163+ require.NoError(t, os.WriteFile(file3, []byte("third"), 0o644))
166
164
167− base := time.Now()
165+ base := time.Now()
168− m1 := base
166+ m1 := base
169− m2 := base.Add(1 * time.Millisecond)
167+ m2 := base.Add(10 * time.Hour)
170− m3 := base.Add(2 * time.Millisecond)
168+ m3 := base.Add(20 * time.Hour)
171
169
172− require.NoError(t, os.Chtimes(file1, m1, m1))
170+ require.NoError(t, os.Chtimes(file1, m1, m1))
173− require.NoError(t, os.Chtimes(file2, m2, m2))
171+ require.NoError(t, os.Chtimes(file2, m2, m2))
174− require.NoError(t, os.Chtimes(file3, m3, m3))
172+ require.NoError(t, os.Chtimes(file3, m3, m3))
175
173
176− matches, truncated, err := GlobWithDoubleStar("*.txt", testDir, 0)
174+ matches, truncated, err := GlobWithDoubleStar("*.txt", testDir, 0)
177− require.NoError(t, err)
175+ require.NoError(t, err)
178− require.False(t, truncated)
176+ require.False(t, truncated)
179
177
180− require.Equal(t, matches, []string{file3, file2, file1})
178+ require.Equal(t, []string{file3, file2, file1}, matches)
181− })
182 })
179 })
183
180
184 t.Run("handles empty directory", func(t *testing.T) {
181 t.Run("handles empty directory", func(t *testing.T) {
@@ -188,7 +185,7 @@ func TestGlobWithDoubleStar(t *testing.T) {
188 require.NoError(t, err)
185 require.NoError(t, err)
189 require.False(t, truncated)
186 require.False(t, truncated)
190 // Even empty directories should return the directory itself
187 // Even empty directories should return the directory itself
191− require.Equal(t, matches, []string{testDir})
188+ require.Equal(t, []string{testDir}, matches)
192 })
189 })
193
190
194 t.Run("handles non-existent search path", func(t *testing.T) {
191 t.Run("handles non-existent search path", func(t *testing.T) {
@@ -235,39 +232,38 @@ func TestGlobWithDoubleStar(t *testing.T) {
235 matches, truncated, err = GlobWithDoubleStar("*.txt", testDir, 0)
232 matches, truncated, err = GlobWithDoubleStar("*.txt", testDir, 0)
236 require.NoError(t, err)
233 require.NoError(t, err)
237 require.False(t, truncated)
234 require.False(t, truncated)
238− require.Equal(t, matches, []string{goodFile})
235+ require.Equal(t, []string{goodFile}, matches)
239 })
236 })
240
237
241 t.Run("handles mixed file and directory matching with sorting", func(t *testing.T) {
238 t.Run("handles mixed file and directory matching with sorting", func(t *testing.T) {
242− synctest.Test(t, func(t *testing.T) {
243− testDir := t.TempDir()
239+ testDir := t.TempDir()
244
240
245− oldestFile := filepath.Join(testDir, "old.test")
241+ oldestFile := filepath.Join(testDir, "old.rs")
246− require.NoError(t, os.WriteFile(oldestFile, []byte("old"), 0o644))
242+ require.NoError(t, os.WriteFile(oldestFile, []byte("old"), 0o644))
247
243
248− middleDir := filepath.Join(testDir, "mid.test")
244+ middleDir := filepath.Join(testDir, "mid.rs")
249− require.NoError(t, os.MkdirAll(middleDir, 0o755))
245+ require.NoError(t, os.MkdirAll(middleDir, 0o755))
250
246
251− newestFile := filepath.Join(testDir, "new.test")
247+ newestFile := filepath.Join(testDir, "new.rs")
252− require.NoError(t, os.WriteFile(newestFile, []byte("new"), 0o644))
248+ require.NoError(t, os.WriteFile(newestFile, []byte("new"), 0o644))
253
249
254− base := time.Now()
250+ base := time.Now()
255− tOldest := base
251+ tOldest := base
256− tMiddle := base.Add(1 * time.Millisecond)
252+ tMiddle := base.Add(10 * time.Hour)
257− tNewest := base.Add(2 * time.Millisecond)
253+ tNewest := base.Add(20 * time.Hour)
258
254
259− // Reverse the expected order
255+ // Reverse the expected order
260− require.NoError(t, os.Chtimes(newestFile, tOldest, tOldest))
256+ require.NoError(t, os.Chtimes(newestFile, tOldest, tOldest))
261− require.NoError(t, os.Chtimes(middleDir, tMiddle, tMiddle))
257+ require.NoError(t, os.Chtimes(middleDir, tMiddle, tMiddle))
262− require.NoError(t, os.Chtimes(oldestFile, tNewest, tNewest))
258+ require.NoError(t, os.Chtimes(oldestFile, tNewest, tNewest))
263
259
264− matches, truncated, err := GlobWithDoubleStar("*.test", testDir, 0)
260+ matches, truncated, err := GlobWithDoubleStar("*.rs", testDir, 0)
265− require.NoError(t, err)
261+ require.NoError(t, err)
266− require.False(t, truncated)
262+ require.False(t, truncated)
263+ require.Len(t, matches, 3)
267
264
268− // Results should be sorted by mod time, but we set the oldestFile
265+ // Results should be sorted by mod time, but we set the oldestFile
269− // to have the most recent mod time
266+ // to have the most recent mod time
270− require.Equal(t, matches, []string{oldestFile, middleDir, newestFile})
267+ require.Equal(t, []string{oldestFile, middleDir, newestFile}, matches)
271− })
272 })
268 })
273}
269}
internal/fsext/ignore_test.go+1−7
@@ -9,14 +9,8 @@ import (
9)
9)
10
10
11func TestCrushIgnore(t *testing.T) {
11func TestCrushIgnore(t *testing.T) {
12− // Create a temporary directory for testing
13 tempDir := t.TempDir()
12 tempDir := t.TempDir()
14−
13+ t.Chdir(tempDir)
15− // Change to temp directory
16− oldWd, _ := os.Getwd()
17− err := os.Chdir(tempDir)
18− require.NoError(t, err)
19− defer os.Chdir(oldWd)
20
14
21 // Create test files
15 // Create test files
22 require.NoError(t, os.WriteFile("test1.txt", []byte("test"), 0o644))
16 require.NoError(t, os.WriteFile("test1.txt", []byte("test"), 0o644))
internal/fsext/lookup_test.go+9−55
@@ -12,15 +12,7 @@ import (
12
12
13func TestLookupClosest(t *testing.T) {
13func TestLookupClosest(t *testing.T) {
14 tempDir := t.TempDir()
14 tempDir := t.TempDir()
15−
16− // Change to temp directory
17− oldWd, _ := os.Getwd()
18− err := os.Chdir(tempDir)
15+ t.Chdir(tempDir)
19− require.NoError(t, err)
20−
21− t.Cleanup(func() {
22− os.Chdir(oldWd)
23− })
24
16
25 t.Run("target found in starting directory", func(t *testing.T) {
17 t.Run("target found in starting directory", func(t *testing.T) {
26 testDir := t.TempDir()
18 testDir := t.TempDir()
@@ -114,24 +106,15 @@ func TestLookupClosest(t *testing.T) {
114 })
106 })
115
107
116 t.Run("relative path handling", func(t *testing.T) {
108 t.Run("relative path handling", func(t *testing.T) {
117− testDir := t.TempDir()
118−
119− // Change to test directory
120− oldWd, _ := os.Getwd()
121− err := os.Chdir(testDir)
122− require.NoError(t, err)
123− defer os.Chdir(oldWd)
124−
125 // Create target file in current directory
109 // Create target file in current directory
126− err = os.WriteFile("target.txt", []byte("test"), 0o644)
110+ require.NoError(t, os.WriteFile("target.txt", []byte("test"), 0o644))
127− require.NoError(t, err)
128
111
129 // Search using relative path
112 // Search using relative path
130 foundPath, found := LookupClosest(".", "target.txt")
113 foundPath, found := LookupClosest(".", "target.txt")
131 require.True(t, found)
114 require.True(t, found)
132
115
133 // Resolve symlinks to handle macOS /private/var vs /var discrepancy
116 // Resolve symlinks to handle macOS /private/var vs /var discrepancy
134− expectedPath, err := filepath.EvalSymlinks(filepath.Join(testDir, "target.txt"))
117+ expectedPath, err := filepath.EvalSymlinks(filepath.Join(tempDir, "target.txt"))
135 require.NoError(t, err)
118 require.NoError(t, err)
136 actualPath, err := filepath.EvalSymlinks(foundPath)
119 actualPath, err := filepath.EvalSymlinks(foundPath)
137 require.NoError(t, err)
120 require.NoError(t, err)
@@ -145,15 +128,7 @@ func TestLookupClosestWithOwnership(t *testing.T) {
145 // This test focuses on the basic functionality when ownership checks pass.
128 // This test focuses on the basic functionality when ownership checks pass.
146
129
147 tempDir := t.TempDir()
130 tempDir := t.TempDir()
148−
149− // Change to temp directory
150− oldWd, _ := os.Getwd()
151− err := os.Chdir(tempDir)
131+ t.Chdir(tempDir)
152− require.NoError(t, err)
153−
154− t.Cleanup(func() {
155− os.Chdir(oldWd)
156− })
157
132
158 t.Run("search respects same ownership", func(t *testing.T) {
133 t.Run("search respects same ownership", func(t *testing.T) {
159 testDir := t.TempDir()
134 testDir := t.TempDir()
@@ -177,15 +152,7 @@ func TestLookupClosestWithOwnership(t *testing.T) {
177
152
178func TestLookup(t *testing.T) {
153func TestLookup(t *testing.T) {
179 tempDir := t.TempDir()
154 tempDir := t.TempDir()
180−
181− // Change to temp directory
182− oldWd, _ := os.Getwd()
183− err := os.Chdir(tempDir)
155+ t.Chdir(tempDir)
184− require.NoError(t, err)
185−
186− t.Cleanup(func() {
187− os.Chdir(oldWd)
188− })
189
156
190 t.Run("no targets returns empty slice", func(t *testing.T) {
157 t.Run("no targets returns empty slice", func(t *testing.T) {
191 testDir := t.TempDir()
158 testDir := t.TempDir()
@@ -358,22 +325,9 @@ func TestLookup(t *testing.T) {
358 })
325 })
359
326
360 t.Run("relative path handling", func(t *testing.T) {
327 t.Run("relative path handling", func(t *testing.T) {
361− testDir := t.TempDir()
362−
363− // Change to test directory
364− oldWd, _ := os.Getwd()
365− err := os.Chdir(testDir)
366− require.NoError(t, err)
367−
368− t.Cleanup(func() {
369− os.Chdir(oldWd)
370− })
371−
372 // Create target files in current directory
328 // Create target files in current directory
373− err = os.WriteFile("target1.txt", []byte("test1"), 0o644)
329+ require.NoError(t, os.WriteFile("target1.txt", []byte("test1"), 0o644))
374− require.NoError(t, err)
375− err = os.WriteFile("target2.txt", []byte("test2"), 0o644)
330+ require.NoError(t, os.WriteFile("target2.txt", []byte("test2"), 0o644))
376− require.NoError(t, err)
377
331
378 // Search using relative path
332 // Search using relative path
379 found, err := Lookup(".", "target1.txt", "target2.txt")
333 found, err := Lookup(".", "target1.txt", "target2.txt")
@@ -381,9 +335,9 @@ func TestLookup(t *testing.T) {
381 require.Len(t, found, 2)
335 require.Len(t, found, 2)
382
336
383 // Resolve symlinks to handle macOS /private/var vs /var discrepancy
337 // Resolve symlinks to handle macOS /private/var vs /var discrepancy
384− expectedPath1, err := filepath.EvalSymlinks(filepath.Join(testDir, "target1.txt"))
338+ expectedPath1, err := filepath.EvalSymlinks(filepath.Join(tempDir, "target1.txt"))
385 require.NoError(t, err)
339 require.NoError(t, err)
386− expectedPath2, err := filepath.EvalSymlinks(filepath.Join(testDir, "target2.txt"))
340+ expectedPath2, err := filepath.EvalSymlinks(filepath.Join(tempDir, "target2.txt"))
387 require.NoError(t, err)
341 require.NoError(t, err)
388
342
389 // Check that found paths match expected paths (order may vary)
343 // Check that found paths match expected paths (order may vary)
internal/fsext/ls.go+20−13
@@ -1,6 +1,7 @@
1package fsext
1package fsext
2
2
3import (
3import (
4+ "errors"
4 "log/slog"
5 "log/slog"
5 "os"
6 "os"
6 "path/filepath"
7 "path/filepath"
@@ -71,6 +72,11 @@ var commonIgnorePatterns = sync.OnceValue(func() ignore.IgnoreParser {
71
72
72 // Crush
73 // Crush
73 ".crush",
74 ".crush",
75+
76+ // macOS stuff
77+ "OrbStack",
78+ ".local",
79+ ".share",
74 )
80 )
75})
81})
76
82
@@ -200,16 +206,17 @@ func (dl *directoryLister) getIgnore(path string) ignore.IgnoreParser {
200}
206}
201
207
202// ListDirectory lists files and directories in the specified path,
208// ListDirectory lists files and directories in the specified path,
203−func ListDirectory(initialPath string, ignorePatterns []string, limit int) ([]string, bool, error) {
209+func ListDirectory(initialPath string, ignorePatterns []string, depth, limit int) ([]string, bool, error) {
204− results := csync.NewSlice[string]()
210+ found := csync.NewSlice[string]()
205− truncated := false
206 dl := NewDirectoryLister(initialPath)
211 dl := NewDirectoryLister(initialPath)
207
212
213+ slog.Warn("listing directory", "path", initialPath, "depth", depth, "limit", limit, "ignorePatterns", ignorePatterns)
214+
208 conf := fastwalk.Config{
215 conf := fastwalk.Config{
209− Follow: true,
216+ Follow: true,
210− // Use forward slashes when running a Windows binary under WSL or MSYS
211− ToSlash: fastwalk.DefaultToSlash(),
217+ ToSlash: fastwalk.DefaultToSlash(),
212− Sort: fastwalk.SortDirsFirst,
218+ Sort: fastwalk.SortDirsFirst,
219+ MaxDepth: depth,
213 }
220 }
214
221
215 err := fastwalk.Walk(&conf, initialPath, func(path string, d os.DirEntry, err error) error {
222 err := fastwalk.Walk(&conf, initialPath, func(path string, d os.DirEntry, err error) error {
@@ -228,19 +235,19 @@ func ListDirectory(initialPath string, ignorePatterns []string, limit int) ([]st
228 if d.IsDir() {
235 if d.IsDir() {
229 path = path + string(filepath.Separator)
236 path = path + string(filepath.Separator)
230 }
237 }
231− results.Append(path)
238+ found.Append(path)
232 }
239 }
233
240
234− if limit > 0 && results.Len() >= limit {
241+ if limit > 0 && found.Len() >= limit {
235− truncated = true
236 return filepath.SkipAll
242 return filepath.SkipAll
237 }
243 }
238
244
239 return nil
245 return nil
240 })
246 })
241− if err != nil && results.Len() == 0 {
247+ if err != nil && !errors.Is(err, filepath.SkipAll) {
242− return nil, truncated, err
248+ return nil, false, err
243 }
249 }
244
250
245− return slices.Collect(results.Seq()), truncated, nil
251+ matches, truncated := truncate(slices.Collect(found.Seq()), limit)
252+ return matches, truncated || errors.Is(err, filepath.SkipAll), nil
246}
253}
internal/fsext/ls_test.go+33−40
@@ -5,26 +5,11 @@ import (
5 "path/filepath"
5 "path/filepath"
6 "testing"
6 "testing"
7
7
8− "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/require"
8 "github.com/stretchr/testify/require"
10)
9)
11
10
12−func chdir(t *testing.T, dir string) {
13− original, err := os.Getwd()
14− require.NoError(t, err)
15−
16− err = os.Chdir(dir)
17− require.NoError(t, err)
18−
19− t.Cleanup(func() {
20− err := os.Chdir(original)
21− require.NoError(t, err)
22− })
23−}
24−
25func TestListDirectory(t *testing.T) {
11func TestListDirectory(t *testing.T) {
26− tempDir := t.TempDir()
12+ tmp := t.TempDir()
27− chdir(t, tempDir)
28
13
29 testFiles := map[string]string{
14 testFiles := map[string]string{
30 "regular.txt": "content",
15 "regular.txt": "content",
@@ -35,32 +20,40 @@ func TestListDirectory(t *testing.T) {
35 "build.log": "build output",
20 "build.log": "build output",
36 }
21 }
37
22
38− for filePath, content := range testFiles {
23+ for name, content := range testFiles {
24+ fp := filepath.Join(tmp, name)
39− dir := filepath.Dir(filePath)
25+ dir := filepath.Dir(fp)
40− if dir != "." {
41− require.NoError(t, os.MkdirAll(dir, 0o755))
26+ require.NoError(t, os.MkdirAll(dir, 0o755))
42− }
43−
44− err := os.WriteFile(filePath, []byte(content), 0o644)
27+ require.NoError(t, os.WriteFile(fp, []byte(content), 0o644))
45− require.NoError(t, err)
46 }
28 }
47
29
30+ t.Run("no limit", func(t *testing.T) {
48− files, truncated, err := ListDirectory(".", nil, 0)
31+ files, truncated, err := ListDirectory(tmp, nil, -1, -1)
49− require.NoError(t, err)
32+ require.NoError(t, err)
50− assert.False(t, truncated)
33+ require.False(t, truncated)
51− assert.Equal(t, len(files), 4)
34+ require.Len(t, files, 4)
35+ require.ElementsMatch(t, []string{
36+ "regular.txt",
37+ "subdir",
38+ "subdir/.another",
39+ "subdir/file.go",
40+ }, relPaths(t, files, tmp))
41+ })
42+ t.Run("limit", func(t *testing.T) {
43+ files, truncated, err := ListDirectory(tmp, nil, -1, 2)
44+ require.NoError(t, err)
45+ require.True(t, truncated)
46+ require.Len(t, files, 2)
47+ })
48+}
52
49
50+func relPaths(tb testing.TB, in []string, base string) []string {
51+ tb.Helper()
53− fileSet := make(map[string]bool)
52+ out := make([]string, 0, len(in))
54− for _, file := range files {
53+ for _, p := range in {
55− fileSet[filepath.ToSlash(file)] = true
54+ rel, err := filepath.Rel(base, p)
55+ require.NoError(tb, err)
56+ out = append(out, filepath.ToSlash(rel))
56 }
57 }
57−
58+ return out
58− assert.True(t, fileSet["./regular.txt"])
59− assert.True(t, fileSet["./subdir/"])
60− assert.True(t, fileSet["./subdir/file.go"])
61− assert.True(t, fileSet["./regular.txt"])
62−
63− assert.False(t, fileSet["./.hidden"])
64− assert.False(t, fileSet["./.gitignore"])
65− assert.False(t, fileSet["./build.log"])
66}
59}
internal/llm/prompt/coder.go+1−1
@@ -53,7 +53,7 @@ func getEnvironmentInfo() string {
53 isGit := isGitRepo(cwd)
53 isGit := isGitRepo(cwd)
54 platform := runtime.GOOS
54 platform := runtime.GOOS
55 date := time.Now().Format("1/2/2006")
55 date := time.Now().Format("1/2/2006")
56− output, _ := tools.ListDirectoryTree(cwd, nil)
56+ output, _, _ := tools.ListDirectoryTree(cwd, tools.LSParams{})
57 return fmt.Sprintf(`Here is useful information about the environment you are running in:
57 return fmt.Sprintf(`Here is useful information about the environment you are running in:
58<env>
58<env>
59Working directory: %s
59Working directory: %s
internal/llm/tools/ls.go+34−27
@@ -1,6 +1,7 @@
1package tools
1package tools
2
2
3import (
3import (
4+ "cmp"
4 "context"
5 "context"
5 _ "embed"
6 _ "embed"
6 "encoding/json"
7 "encoding/json"
@@ -9,6 +10,7 @@ import (
9 "path/filepath"
10 "path/filepath"
10 "strings"
11 "strings"
11
12
13+ "github.com/charmbracelet/crush/internal/config"
12 "github.com/charmbracelet/crush/internal/fsext"
14 "github.com/charmbracelet/crush/internal/fsext"
13 "github.com/charmbracelet/crush/internal/permission"
15 "github.com/charmbracelet/crush/internal/permission"
14)
16)
@@ -16,11 +18,13 @@ import (
16type LSParams struct {
18type LSParams struct {
17 Path string `json:"path"`
19 Path string `json:"path"`
18 Ignore []string `json:"ignore"`
20 Ignore []string `json:"ignore"`
21+ Depth int `json:"depth"`
19}
22}
20
23
21type LSPermissionsParams struct {
24type LSPermissionsParams struct {
22 Path string `json:"path"`
25 Path string `json:"path"`
23 Ignore []string `json:"ignore"`
26 Ignore []string `json:"ignore"`
27+ Depth int `json:"depth"`
24}
28}
25
29
26type TreeNode struct {
30type TreeNode struct {
@@ -42,7 +46,7 @@ type lsTool struct {
42
46
43const (
47const (
44 LSToolName = "ls"
48 LSToolName = "ls"
45− MaxLSFiles = 1000
49+ maxLSFiles = 1000
46)
50)
47
51
48//go:embed ls.md
52//go:embed ls.md
@@ -68,6 +72,10 @@ func (l *lsTool) Info() ToolInfo {
68 "type": "string",
72 "type": "string",
69 "description": "The path to the directory to list (defaults to current working directory)",
73 "description": "The path to the directory to list (defaults to current working directory)",
70 },
74 },
75+ "depth": map[string]any{
76+ "type": "integer",
77+ "description": "The maximum depth to traverse",
78+ },
71 "ignore": map[string]any{
79 "ignore": map[string]any{
72 "type": "array",
80 "type": "array",
73 "description": "List of glob patterns to ignore",
81 "description": "List of glob patterns to ignore",
@@ -86,13 +94,7 @@ func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
86 return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
94 return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
87 }
95 }
88
96
89− searchPath := params.Path
97+ searchPath, err := fsext.Expand(cmp.Or(params.Path, l.workingDir))
90− if searchPath == "" {
91− searchPath = l.workingDir
92− }
93−
94− var err error
95− searchPath, err = fsext.Expand(searchPath)
96 if err != nil {
98 if err != nil {
97 return ToolResponse{}, fmt.Errorf("error expanding path: %w", err)
99 return ToolResponse{}, fmt.Errorf("error expanding path: %w", err)
98 }
100 }
@@ -137,44 +139,49 @@ func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
137 }
139 }
138 }
140 }
139
141
140− output, err := ListDirectoryTree(searchPath, params.Ignore)
142+ output, metadata, err := ListDirectoryTree(searchPath, params)
141 if err != nil {
143 if err != nil {
142 return ToolResponse{}, err
144 return ToolResponse{}, err
143 }
145 }
144
146
145− // Get file count for metadata
146− files, truncated, err := fsext.ListDirectory(searchPath, params.Ignore, MaxLSFiles)
147− if err != nil {
148− return ToolResponse{}, fmt.Errorf("error listing directory for metadata: %w", err)
149− }
150−
151 return WithResponseMetadata(
147 return WithResponseMetadata(
152 NewTextResponse(output),
148 NewTextResponse(output),
153− LSResponseMetadata{
149+ metadata,
154− NumberOfFiles: len(files),
155− Truncated: truncated,
156− },
157 ), nil
150 ), nil
158}
151}
159
152
160−func ListDirectoryTree(searchPath string, ignore []string) (string, error) {
153+func ListDirectoryTree(searchPath string, params LSParams) (string, LSResponseMetadata, error) {
161 if _, err := os.Stat(searchPath); os.IsNotExist(err) {
154 if _, err := os.Stat(searchPath); os.IsNotExist(err) {
162− return "", fmt.Errorf("path does not exist: %s", searchPath)
155+ return "", LSResponseMetadata{}, fmt.Errorf("path does not exist: %s", searchPath)
163 }
156 }
164
157
158+ ls := config.Get().Tools.Ls
159+ depth, limit := ls.Limits()
160+ maxFiles := min(limit, maxLSFiles)
165− files, truncated, err := fsext.ListDirectory(searchPath, ignore, MaxLSFiles)
161+ files, truncated, err := fsext.ListDirectory(
162+ searchPath,
163+ params.Ignore,
164+ cmp.Or(params.Depth, depth),
165+ maxFiles,
166+ )
166 if err != nil {
167 if err != nil {
167− return "", fmt.Errorf("error listing directory: %w", err)
168+ return "", LSResponseMetadata{}, fmt.Errorf("error listing directory: %w", err)
168 }
169 }
169
170
171+ metadata := LSResponseMetadata{
172+ NumberOfFiles: len(files),
173+ Truncated: truncated,
174+ }
170 tree := createFileTree(files, searchPath)
175 tree := createFileTree(files, searchPath)
171− output := printTree(tree, searchPath)
172
176
177+ var output string
173 if truncated {
178 if truncated {
174− output = fmt.Sprintf("There are more than %d files in the directory. Use a more specific path or use the Glob tool to find specific files. The first %d files and directories are included below:\n\n%s", MaxLSFiles, MaxLSFiles, output)
179+ output = fmt.Sprintf("There are more than %d files in the directory. Use a more specific path or use the Glob tool to find specific files. The first %[1]d files and directories are included below.\n", maxFiles)
175 }
180 }
176−
181+ if depth > 0 {
182+ output = fmt.Sprintf("The directory tree is shown up to a depth of %d. Use a higher depth and a specific path to see more levels.\n", cmp.Or(params.Depth, depth))
183+ }
177− return output, nil
184+ return output + "\n" + printTree(tree, searchPath), metadata, nil
178}
185}
179
186
180func createFileTree(sortedPaths []string, rootPath string) []*TreeNode {
187func createFileTree(sortedPaths []string, rootPath string) []*TreeNode {
internal/tui/components/chat/editor/editor.go+3−1
@@ -480,7 +480,9 @@ func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
480}
480}
481
481
482func (m *editorCmp) startCompletions() tea.Msg {
482func (m *editorCmp) startCompletions() tea.Msg {
483+ ls := m.app.Config().Options.TUI.Completions
484+ depth, limit := ls.Limits()
483− files, _, _ := fsext.ListDirectory(".", nil, 0)
485+ files, _, _ := fsext.ListDirectory(".", nil, depth, limit)
484 slices.Sort(files)
486 slices.Sort(files)
485 completionItems := make([]completions.Completion, 0, len(files))
487 completionItems := make([]completions.Completion, 0, len(files))
486 for _, file := range files {
488 for _, file := range files {
schema.json+71−1
@@ -19,6 +19,28 @@
19 "additionalProperties": false,
19 "additionalProperties": false,
20 "type": "object"
20 "type": "object"
21 },
21 },
22+ "Completions": {
23+ "properties": {
24+ "max_depth": {
25+ "type": "integer",
26+ "description": "Maximum depth for the ls tool",
27+ "default": 0,
28+ "examples": [
29+ 10
30+ ]
31+ },
32+ "max_items": {
33+ "type": "integer",
34+ "description": "Maximum number of items to return for the ls tool",
35+ "default": 1000,
36+ "examples": [
37+ 100
38+ ]
39+ }
40+ },
41+ "additionalProperties": false,
42+ "type": "object"
43+ },
22 "Config": {
44 "Config": {
23 "properties": {
45 "properties": {
24 "$schema": {
46 "$schema": {
@@ -53,10 +75,17 @@
53 "permissions": {
75 "permissions": {
54 "$ref": "#/$defs/Permissions",
76 "$ref": "#/$defs/Permissions",
55 "description": "Permission settings for tool usage"
77 "description": "Permission settings for tool usage"
78+ },
79+ "tools": {
80+ "$ref": "#/$defs/Tools",
81+ "description": "Tool configurations"
56 }
82 }
57 },
83 },
58 "additionalProperties": false,
84 "additionalProperties": false,
59− "type": "object"
85+ "type": "object",
86+ "required": [
87+ "tools"
88+ ]
60 },
89 },
61 "LSPConfig": {
90 "LSPConfig": {
62 "properties": {
91 "properties": {
@@ -484,10 +513,51 @@
484 "split"
513 "split"
485 ],
514 ],
486 "description": "Diff mode for the TUI interface"
515 "description": "Diff mode for the TUI interface"
516+ },
517+ "completions": {
518+ "$ref": "#/$defs/Completions",
519+ "description": "Completions UI options"
520+ }
521+ },
522+ "additionalProperties": false,
523+ "type": "object",
524+ "required": [
525+ "completions"
526+ ]
527+ },
528+ "ToolLs": {
529+ "properties": {
530+ "max_depth": {
531+ "type": "integer",
532+ "description": "Maximum depth for the ls tool",
533+ "default": 0,
534+ "examples": [
535+ 10
536+ ]
537+ },
538+ "max_items": {
539+ "type": "integer",
540+ "description": "Maximum number of items to return for the ls tool",
541+ "default": 1000,
542+ "examples": [
543+ 100
544+ ]
487 }
545 }
488 },
546 },
489 "additionalProperties": false,
547 "additionalProperties": false,
490 "type": "object"
548 "type": "object"
549+ },
550+ "Tools": {
551+ "properties": {
552+ "ls": {
553+ "$ref": "#/$defs/ToolLs"
554+ }
555+ },
556+ "additionalProperties": false,
557+ "type": "object",
558+ "required": [
559+ "ls"
560+ ]
491 }
561 }
492 }
562 }
493}
563}