detached 4685a30

Merge pull request #28 from rgasper/file-picker-improvements

File picker improvements
commit
4685a30ec4cd4157f70692f4a56d8215836a2dc5
author
Patric Eckhart · · 2026-06-10 18:55 UTC
committer
GitHub · · 2026-06-10 18:55 UTC
merge of
9b298e6 + 4a8d2ed · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.2.28
5 files · +348 -13
M packages/agent/modes/file_suggest.go +52 -5
@@ -17,9 +17,21 @@ import (
1717 // recursiveScanLimits bound the recursive walk so the picker stays
1818 // responsive in very large repos. Hitting either cap stops the walk
1919 // early; the entries gathered so far are still searchable.
20+//
21+// The entry cap is set against the real bottleneck, which is not memory
22+// but the per-keystroke fuzzy.Find ranking that scores every entry. A
23+// fileEntry is ~120 bytes all-in (40-byte struct header plus the path
24+// string), so even 50k entries is only ~6 MB. fuzzy.Find scales roughly
25+// linearly: ~2 ms at 5k, ~13 ms at 50k, ~21 ms at 100k on a typical
26+// laptop. 50k keeps ranking under one 60 Hz frame (~16 ms) while
27+// comfortably holding a large monorepo once nested .gitignore pruning
28+// (node_modules, build outputs, tool caches) has done its job. The
29+// depth cap likewise guards against pathologically deep trees; 24
30+// levels is far below anything a human navigates yet still reaches the
31+// deeply nested source files real monorepos bury.
2032 const (
21- maxRecursiveEntries = 5000
22- maxRecursiveDepth = 12
33+ maxRecursiveEntries = 50000
34+ maxRecursiveDepth = 24
2335 )
2436
2537 // alwaysSkipDir is never descended into during a recursive scan,
@@ -195,12 +207,22 @@ func (s *fileSuggester) scanRecursive() []fileEntry {
195207 return s.cachedAll
196208 }
197209
198- var ig *ignore.Gitignore
210+ var stack *ignore.Stack
199211 if s.respectGitignore {
200- ig = ignore.Load(root)
212+ stack = ignore.NewStack(root)
201213 }
202214 var all []fileEntry
203215 rootSep := strings.Count(root, string(os.PathSeparator))
216+ // pushed mirrors the directories whose nested .gitignore we've
217+ // brought into scope on stack but not yet dropped. WalkDir is
218+ // depth-first in lexical order, so before each entry we pop frames
219+ // for directories we've finished walking (those that are no longer
220+ // an ancestor of the current path), then push the current
221+ // directory. Honoring nested .gitignore files is what stops a
222+ // vendored node_modules whose ignore rule lives in a subdirectory
223+ // (e.g. .opencode/.gitignore) from swamping the entry budget before
224+ // the walk ever reaches the user's deeply nested source file.
225+ var pushed []string
204226 _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
205227 if err != nil {
206228 if d != nil && d.IsDir() {
@@ -215,9 +237,28 @@ func (s *fileSuggester) scanRecursive() []fileEntry {
215237 if relErr != nil {
216238 return nil
217239 }
240+ relSlash := filepath.ToSlash(rel)
218241 if s.respectGitignore {
242+ // Drop stack frames for directories we've finished walking:
243+ // any pushed dir that is not an ancestor of this entry's dir.
244+ dirSlash := relSlash
245+ if !d.IsDir() {
246+ if i := strings.LastIndex(relSlash, "/"); i >= 0 {
247+ dirSlash = relSlash[:i]
248+ } else {
249+ dirSlash = ""
250+ }
251+ }
252+ for len(pushed) > 0 {
253+ top := pushed[len(pushed)-1]
254+ if top == dirSlash || strings.HasPrefix(dirSlash, top+"/") {
255+ break
256+ }
257+ pushed = pushed[:len(pushed)-1]
258+ stack.Pop()
259+ }
219260 // .gitignore patterns are matched against slash-separated paths.
220- if ig.Match(filepath.ToSlash(rel), d.IsDir()) {
261+ if stack.Match(relSlash, d.IsDir()) {
221262 if d.IsDir() {
222263 return filepath.SkipDir
223264 }
@@ -234,6 +275,12 @@ func (s *fileSuggester) scanRecursive() []fileEntry {
234275 if strings.Count(path, string(os.PathSeparator))-rootSep >= maxRecursiveDepth {
235276 return filepath.SkipDir
236277 }
278+ // This directory is kept; bring its nested .gitignore (if
279+ // any) into scope for the descendants we're about to visit.
280+ if s.respectGitignore {
281+ stack.Push(path, rel)
282+ pushed = append(pushed, relSlash)
283+ }
237284 }
238285 all = append(all, fileEntry{
239286 name: rel,
@@ -17,9 +17,21 @@ import (
17 // recursiveScanLimits bound the recursive walk so the picker stays17 // recursiveScanLimits bound the recursive walk so the picker stays
18 // responsive in very large repos. Hitting either cap stops the walk18 // responsive in very large repos. Hitting either cap stops the walk
19 // early; the entries gathered so far are still searchable.19 // early; the entries gathered so far are still searchable.
20+//
21+// The entry cap is set against the real bottleneck, which is not memory
22+// but the per-keystroke fuzzy.Find ranking that scores every entry. A
23+// fileEntry is ~120 bytes all-in (40-byte struct header plus the path
24+// string), so even 50k entries is only ~6 MB. fuzzy.Find scales roughly
25+// linearly: ~2 ms at 5k, ~13 ms at 50k, ~21 ms at 100k on a typical
26+// laptop. 50k keeps ranking under one 60 Hz frame (~16 ms) while
27+// comfortably holding a large monorepo once nested .gitignore pruning
28+// (node_modules, build outputs, tool caches) has done its job. The
29+// depth cap likewise guards against pathologically deep trees; 24
30+// levels is far below anything a human navigates yet still reaches the
31+// deeply nested source files real monorepos bury.
20 const (32 const (
21- maxRecursiveEntries = 500033+ maxRecursiveEntries = 50000
22- maxRecursiveDepth = 1234+ maxRecursiveDepth = 24
23 )35 )
24 36
25 // alwaysSkipDir is never descended into during a recursive scan,37 // alwaysSkipDir is never descended into during a recursive scan,
@@ -195,12 +207,22 @@ func (s *fileSuggester) scanRecursive() []fileEntry {
195 return s.cachedAll207 return s.cachedAll
196 }208 }
197 209
198- var ig *ignore.Gitignore210+ var stack *ignore.Stack
199 if s.respectGitignore {211 if s.respectGitignore {
200- ig = ignore.Load(root)212+ stack = ignore.NewStack(root)
201 }213 }
202 var all []fileEntry214 var all []fileEntry
203 rootSep := strings.Count(root, string(os.PathSeparator))215 rootSep := strings.Count(root, string(os.PathSeparator))
216+ // pushed mirrors the directories whose nested .gitignore we've
217+ // brought into scope on stack but not yet dropped. WalkDir is
218+ // depth-first in lexical order, so before each entry we pop frames
219+ // for directories we've finished walking (those that are no longer
220+ // an ancestor of the current path), then push the current
221+ // directory. Honoring nested .gitignore files is what stops a
222+ // vendored node_modules whose ignore rule lives in a subdirectory
223+ // (e.g. .opencode/.gitignore) from swamping the entry budget before
224+ // the walk ever reaches the user's deeply nested source file.
225+ var pushed []string
204 _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {226 _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
205 if err != nil {227 if err != nil {
206 if d != nil && d.IsDir() {228 if d != nil && d.IsDir() {
@@ -215,9 +237,28 @@ func (s *fileSuggester) scanRecursive() []fileEntry {
215 if relErr != nil {237 if relErr != nil {
216 return nil238 return nil
217 }239 }
240+ relSlash := filepath.ToSlash(rel)
218 if s.respectGitignore {241 if s.respectGitignore {
242+ // Drop stack frames for directories we've finished walking:
243+ // any pushed dir that is not an ancestor of this entry's dir.
244+ dirSlash := relSlash
245+ if !d.IsDir() {
246+ if i := strings.LastIndex(relSlash, "/"); i >= 0 {
247+ dirSlash = relSlash[:i]
248+ } else {
249+ dirSlash = ""
250+ }
251+ }
252+ for len(pushed) > 0 {
253+ top := pushed[len(pushed)-1]
254+ if top == dirSlash || strings.HasPrefix(dirSlash, top+"/") {
255+ break
256+ }
257+ pushed = pushed[:len(pushed)-1]
258+ stack.Pop()
259+ }
219 // .gitignore patterns are matched against slash-separated paths.260 // .gitignore patterns are matched against slash-separated paths.
220- if ig.Match(filepath.ToSlash(rel), d.IsDir()) {261+ if stack.Match(relSlash, d.IsDir()) {
221 if d.IsDir() {262 if d.IsDir() {
222 return filepath.SkipDir263 return filepath.SkipDir
223 }264 }
@@ -234,6 +275,12 @@ func (s *fileSuggester) scanRecursive() []fileEntry {
234 if strings.Count(path, string(os.PathSeparator))-rootSep >= maxRecursiveDepth {275 if strings.Count(path, string(os.PathSeparator))-rootSep >= maxRecursiveDepth {
235 return filepath.SkipDir276 return filepath.SkipDir
236 }277 }
278+ // This directory is kept; bring its nested .gitignore (if
279+ // any) into scope for the descendants we're about to visit.
280+ if s.respectGitignore {
281+ stack.Push(path, rel)
282+ pushed = append(pushed, relSlash)
283+ }
237 }284 }
238 all = append(all, fileEntry{285 all = append(all, fileEntry{
239 name: rel,286 name: rel,
M packages/agent/modes/file_suggest_test.go +103 -0
@@ -79,6 +79,61 @@ func TestFileSuggesterPicksUpNewEntries(t *testing.T) {
7979 }
8080 }
8181
82+// TestFileSuggesterRecursiveHonorsNestedGitignore reproduces the
83+// reported bug: a vendored tool directory (.opencode) carries its own
84+// .gitignore that excludes node_modules, but the repo root .gitignore
85+// says nothing about it. Before nested .gitignore support the
86+// recursive walk surfaced thousands of node_modules files and a deeply
87+// nested real source file (eda/rjg/enk-1150/pipeline.py) could not be
88+// found. The walk must prune the nested-ignored tree and surface the
89+// deep file.
90+func TestFileSuggesterRecursiveHonorsNestedGitignore(t *testing.T) {
91+ tmp := t.TempDir()
92+ // Root .gitignore knows nothing about node_modules.
93+ if err := os.WriteFile(filepath.Join(tmp, ".gitignore"), []byte("dist/\n"), 0o644); err != nil {
94+ t.Fatal(err)
95+ }
96+ // .opencode/.gitignore ignores its own node_modules.
97+ opencodeNM := filepath.Join(tmp, ".opencode", "node_modules", "zod", "src")
98+ if err := os.MkdirAll(opencodeNM, 0o755); err != nil {
99+ t.Fatal(err)
100+ }
101+ if err := os.WriteFile(filepath.Join(tmp, ".opencode", ".gitignore"), []byte("node_modules\n"), 0o644); err != nil {
102+ t.Fatal(err)
103+ }
104+ if err := os.WriteFile(filepath.Join(opencodeNM, "pipeline.test.ts"), []byte("x"), 0o644); err != nil {
105+ t.Fatal(err)
106+ }
107+ // The deeply nested real file the user is hunting for.
108+ deep := filepath.Join(tmp, "eda", "rjg", "enk-1150")
109+ if err := os.MkdirAll(deep, 0o755); err != nil {
110+ t.Fatal(err)
111+ }
112+ if err := os.WriteFile(filepath.Join(deep, "pipeline.py"), []byte("x"), 0o644); err != nil {
113+ t.Fatal(err)
114+ }
115+
116+ s := newFileSuggester()
117+ s.SetCWD(tmp)
118+ s.SetRecursive(true)
119+
120+ all := s.scan()
121+ for _, e := range all {
122+ if strings.Contains(filepath.ToSlash(e.rel), "node_modules") {
123+ t.Fatalf("recursive scan surfaced nested-gitignored node_modules: %#v", e)
124+ }
125+ }
126+ rel := filepath.Join("eda", "rjg", "enk-1150", "pipeline.py")
127+ if !containsEntry(all, rel, false) {
128+ t.Fatalf("recursive scan missing deep pipeline.py: %#v", all)
129+ }
130+ // The fuzzy query should now find the real file.
131+ got := s.matches("@pipeline.py")
132+ if !containsEntry(got, rel, false) {
133+ t.Fatalf("@pipeline.py did not match the deep file: %#v", got)
134+ }
135+}
136+
82137 func containsEntry(entries []fileEntry, name string, isDir bool) bool {
83138 for _, e := range entries {
84139 if e.name == name && e.isDir == isDir {
@@ -121,6 +176,54 @@ func TestFileSuggesterFuzzyMatch(t *testing.T) {
121176 }
122177 }
123178
179+// TestFileSuggesterFlatBrowseIntoDirIgnoresStaleFilter reproduces the
180+// reported bug: in flat (non-recursive) mode, typing "@eda" to find a
181+// directory then opening it with Right must show that directory's
182+// contents, not re-apply the "eda" filter inside it (which matches
183+// nothing). The interactive layer clears the @-query when descending,
184+// so here we model that by browsing with Right and then matching an
185+// empty query against the new level.
186+func TestFileSuggesterFlatBrowseIntoDirIgnoresStaleFilter(t *testing.T) {
187+ tmp := t.TempDir()
188+ // eda/rjg/enk-1150 with a file inside, plus a sibling so the filter
189+ // is meaningful at the top level.
190+ if err := os.MkdirAll(filepath.Join(tmp, "eda", "rjg", "enk-1150"), 0o755); err != nil {
191+ t.Fatal(err)
192+ }
193+ if err := os.WriteFile(filepath.Join(tmp, "eda", "rjg", "enk-1150", "pipeline.py"), []byte("x"), 0o644); err != nil {
194+ t.Fatal(err)
195+ }
196+ if err := os.MkdirAll(filepath.Join(tmp, "unrelated"), 0o755); err != nil {
197+ t.Fatal(err)
198+ }
199+
200+ s := newFileSuggester()
201+ s.SetCWD(tmp) // flat mode
202+
203+ // Top level: "@eda" highlights the eda/ directory. Render populates
204+ // lastMatches, which Right/Left act on (it runs every frame before
205+ // key handling in the live UI).
206+ s.lastMatches = s.matches("@eda")
207+ if !containsEntry(s.lastMatches, "eda", true) {
208+ t.Fatalf("@eda did not match eda/: %#v", s.lastMatches)
209+ }
210+ s.cursor = 0 // eda/ is the (only) match, selected.
211+
212+ // Open it. After the interactive layer clears the query, the picker
213+ // is browsing eda/ with an empty filter and must show rjg/.
214+ if !s.Right() {
215+ t.Fatal("Right() did not open eda/")
216+ }
217+ if got := s.matches("@"); !containsEntry(got, "rjg", true) {
218+ t.Fatalf("after opening eda/, empty filter did not show rjg/: %#v", got)
219+ }
220+ // The stale filter would have shown nothing: confirm that's the
221+ // behavior the fix avoids.
222+ if got := s.matches("@eda"); len(got) != 0 {
223+ t.Fatalf("stale @eda filter inside eda/ unexpectedly matched: %#v", got)
224+ }
225+}
226+
124227 // TestFileSuggesterRecursiveMatch verifies recursive mode flattens the
125228 // tree and matches against the cwd-relative path, so a pattern can
126229 // span directory boundaries.
@@ -79,6 +79,61 @@ func TestFileSuggesterPicksUpNewEntries(t *testing.T) {
79 }79 }
80 }80 }
81 81
82+// TestFileSuggesterRecursiveHonorsNestedGitignore reproduces the
83+// reported bug: a vendored tool directory (.opencode) carries its own
84+// .gitignore that excludes node_modules, but the repo root .gitignore
85+// says nothing about it. Before nested .gitignore support the
86+// recursive walk surfaced thousands of node_modules files and a deeply
87+// nested real source file (eda/rjg/enk-1150/pipeline.py) could not be
88+// found. The walk must prune the nested-ignored tree and surface the
89+// deep file.
90+func TestFileSuggesterRecursiveHonorsNestedGitignore(t *testing.T) {
91+ tmp := t.TempDir()
92+ // Root .gitignore knows nothing about node_modules.
93+ if err := os.WriteFile(filepath.Join(tmp, ".gitignore"), []byte("dist/\n"), 0o644); err != nil {
94+ t.Fatal(err)
95+ }
96+ // .opencode/.gitignore ignores its own node_modules.
97+ opencodeNM := filepath.Join(tmp, ".opencode", "node_modules", "zod", "src")
98+ if err := os.MkdirAll(opencodeNM, 0o755); err != nil {
99+ t.Fatal(err)
100+ }
101+ if err := os.WriteFile(filepath.Join(tmp, ".opencode", ".gitignore"), []byte("node_modules\n"), 0o644); err != nil {
102+ t.Fatal(err)
103+ }
104+ if err := os.WriteFile(filepath.Join(opencodeNM, "pipeline.test.ts"), []byte("x"), 0o644); err != nil {
105+ t.Fatal(err)
106+ }
107+ // The deeply nested real file the user is hunting for.
108+ deep := filepath.Join(tmp, "eda", "rjg", "enk-1150")
109+ if err := os.MkdirAll(deep, 0o755); err != nil {
110+ t.Fatal(err)
111+ }
112+ if err := os.WriteFile(filepath.Join(deep, "pipeline.py"), []byte("x"), 0o644); err != nil {
113+ t.Fatal(err)
114+ }
115+
116+ s := newFileSuggester()
117+ s.SetCWD(tmp)
118+ s.SetRecursive(true)
119+
120+ all := s.scan()
121+ for _, e := range all {
122+ if strings.Contains(filepath.ToSlash(e.rel), "node_modules") {
123+ t.Fatalf("recursive scan surfaced nested-gitignored node_modules: %#v", e)
124+ }
125+ }
126+ rel := filepath.Join("eda", "rjg", "enk-1150", "pipeline.py")
127+ if !containsEntry(all, rel, false) {
128+ t.Fatalf("recursive scan missing deep pipeline.py: %#v", all)
129+ }
130+ // The fuzzy query should now find the real file.
131+ got := s.matches("@pipeline.py")
132+ if !containsEntry(got, rel, false) {
133+ t.Fatalf("@pipeline.py did not match the deep file: %#v", got)
134+ }
135+}
136+
82 func containsEntry(entries []fileEntry, name string, isDir bool) bool {137 func containsEntry(entries []fileEntry, name string, isDir bool) bool {
83 for _, e := range entries {138 for _, e := range entries {
84 if e.name == name && e.isDir == isDir {139 if e.name == name && e.isDir == isDir {
@@ -121,6 +176,54 @@ func TestFileSuggesterFuzzyMatch(t *testing.T) {
121 }176 }
122 }177 }
123 178
179+// TestFileSuggesterFlatBrowseIntoDirIgnoresStaleFilter reproduces the
180+// reported bug: in flat (non-recursive) mode, typing "@eda" to find a
181+// directory then opening it with Right must show that directory's
182+// contents, not re-apply the "eda" filter inside it (which matches
183+// nothing). The interactive layer clears the @-query when descending,
184+// so here we model that by browsing with Right and then matching an
185+// empty query against the new level.
186+func TestFileSuggesterFlatBrowseIntoDirIgnoresStaleFilter(t *testing.T) {
187+ tmp := t.TempDir()
188+ // eda/rjg/enk-1150 with a file inside, plus a sibling so the filter
189+ // is meaningful at the top level.
190+ if err := os.MkdirAll(filepath.Join(tmp, "eda", "rjg", "enk-1150"), 0o755); err != nil {
191+ t.Fatal(err)
192+ }
193+ if err := os.WriteFile(filepath.Join(tmp, "eda", "rjg", "enk-1150", "pipeline.py"), []byte("x"), 0o644); err != nil {
194+ t.Fatal(err)
195+ }
196+ if err := os.MkdirAll(filepath.Join(tmp, "unrelated"), 0o755); err != nil {
197+ t.Fatal(err)
198+ }
199+
200+ s := newFileSuggester()
201+ s.SetCWD(tmp) // flat mode
202+
203+ // Top level: "@eda" highlights the eda/ directory. Render populates
204+ // lastMatches, which Right/Left act on (it runs every frame before
205+ // key handling in the live UI).
206+ s.lastMatches = s.matches("@eda")
207+ if !containsEntry(s.lastMatches, "eda", true) {
208+ t.Fatalf("@eda did not match eda/: %#v", s.lastMatches)
209+ }
210+ s.cursor = 0 // eda/ is the (only) match, selected.
211+
212+ // Open it. After the interactive layer clears the query, the picker
213+ // is browsing eda/ with an empty filter and must show rjg/.
214+ if !s.Right() {
215+ t.Fatal("Right() did not open eda/")
216+ }
217+ if got := s.matches("@"); !containsEntry(got, "rjg", true) {
218+ t.Fatalf("after opening eda/, empty filter did not show rjg/: %#v", got)
219+ }
220+ // The stale filter would have shown nothing: confirm that's the
221+ // behavior the fix avoids.
222+ if got := s.matches("@eda"); len(got) != 0 {
223+ t.Fatalf("stale @eda filter inside eda/ unexpectedly matched: %#v", got)
224+ }
225+}
226+
124 // TestFileSuggesterRecursiveMatch verifies recursive mode flattens the227 // TestFileSuggesterRecursiveMatch verifies recursive mode flattens the
125 // tree and matches against the cwd-relative path, so a pattern can228 // tree and matches against the cwd-relative path, so a pattern can
126 // span directory boundaries.229 // span directory boundaries.
M packages/agent/modes/interactive.go +25 -4
@@ -1605,6 +1605,18 @@ func (i *Interactive) ctrlCExitArmed() bool {
16051605 return !t.IsZero() && time.Since(t) <= ctrlCExitWindow
16061606 }
16071607
1608+// clearFileSuggestQuery strips the filter the user typed after the
1609+// last "@", leaving the bare "@" so the picker stays open. Called when
1610+// navigating between directory levels (Right/Left): the filter applied
1611+// to the level the user was on, not the one being entered, so carrying
1612+// it forward would wrongly hide the new directory's contents.
1613+func (i *Interactive) clearFileSuggestQuery() {
1614+ val := i.ed.Value()
1615+ if idx := strings.LastIndex(val, "@"); idx >= 0 {
1616+ i.ed.SetValue(val[:idx+1])
1617+ }
1618+}
1619+
16081620 func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
16091621 // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so
16101622 // pressing ctrl+c then typing then ctrl+c much later doesn't quit
@@ -2083,12 +2095,21 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
20832095 i.fileSuggest.Down()
20842096 return false
20852097 case tui.KeyRight:
2086- // Open selected directory.
2087- i.fileSuggest.Right()
2098+ // Open selected directory. The filter the user typed picked
2099+ // that directory at the current level; once we descend it no
2100+ // longer applies to the directory's contents, so clear it.
2101+ // Otherwise typing "@eda" then right would re-filter inside
2102+ // eda/ by "eda" and show nothing.
2103+ if i.fileSuggest.Right() {
2104+ i.clearFileSuggestQuery()
2105+ }
20882106 return false
20892107 case tui.KeyLeft:
2090- // Go back to parent directory.
2091- i.fileSuggest.Left()
2108+ // Go back to parent directory. Clear the filter for the same
2109+ // reason as Right: it was scoped to the level we just left.
2110+ if i.fileSuggest.Left() {
2111+ i.clearFileSuggestQuery()
2112+ }
20922113 return false
20932114 case tui.KeyEnter:
20942115 if entry, ok := i.fileSuggest.SelectedEntry(i.ed.Value()); ok {
@@ -1605,6 +1605,18 @@ func (i *Interactive) ctrlCExitArmed() bool {
1605 return !t.IsZero() && time.Since(t) <= ctrlCExitWindow1605 return !t.IsZero() && time.Since(t) <= ctrlCExitWindow
1606 }1606 }
1607 1607
1608+// clearFileSuggestQuery strips the filter the user typed after the
1609+// last "@", leaving the bare "@" so the picker stays open. Called when
1610+// navigating between directory levels (Right/Left): the filter applied
1611+// to the level the user was on, not the one being entered, so carrying
1612+// it forward would wrongly hide the new directory's contents.
1613+func (i *Interactive) clearFileSuggestQuery() {
1614+ val := i.ed.Value()
1615+ if idx := strings.LastIndex(val, "@"); idx >= 0 {
1616+ i.ed.SetValue(val[:idx+1])
1617+ }
1618+}
1619+
1608 func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {1620 func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
1609 // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so1621 // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so
1610 // pressing ctrl+c then typing then ctrl+c much later doesn't quit1622 // pressing ctrl+c then typing then ctrl+c much later doesn't quit
@@ -2083,12 +2095,21 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2083 i.fileSuggest.Down()2095 i.fileSuggest.Down()
2084 return false2096 return false
2085 case tui.KeyRight:2097 case tui.KeyRight:
2086- // Open selected directory.2098+ // Open selected directory. The filter the user typed picked
2087- i.fileSuggest.Right()2099+ // that directory at the current level; once we descend it no
2100+ // longer applies to the directory's contents, so clear it.
2101+ // Otherwise typing "@eda" then right would re-filter inside
2102+ // eda/ by "eda" and show nothing.
2103+ if i.fileSuggest.Right() {
2104+ i.clearFileSuggestQuery()
2105+ }
2088 return false2106 return false
2089 case tui.KeyLeft:2107 case tui.KeyLeft:
2090- // Go back to parent directory.2108+ // Go back to parent directory. Clear the filter for the same
2091- i.fileSuggest.Left()2109+ // reason as Right: it was scoped to the level we just left.
2110+ if i.fileSuggest.Left() {
2111+ i.clearFileSuggestQuery()
2112+ }
2092 return false2113 return false
2093 case tui.KeyEnter:2114 case tui.KeyEnter:
2094 if entry, ok := i.fileSuggest.SelectedEntry(i.ed.Value()); ok {2115 if entry, ok := i.fileSuggest.SelectedEntry(i.ed.Value()); ok {
M packages/ignore/gitignore.go +91 -3
@@ -37,6 +37,84 @@ func Load(root string) *Gitignore {
3737 return Parse(string(data))
3838 }
3939
40+// Stack tracks the chain of .gitignore files from a walk's root down to
41+// the directory currently being visited. Real repositories routinely
42+// place a .gitignore inside a subdirectory (for example a vendored tool
43+// directory that ignores its own node_modules), and those nested rules
44+// are invisible to a single root-only matcher. During a recursive walk
45+// such an unhonored node_modules can swamp the entry budget before the
46+// walk ever reaches the files the user is actually looking for, so the
47+// nested files must be applied.
48+//
49+// Each frame holds the matcher parsed from a directory's .gitignore
50+// plus that directory's path relative to the walk root (slash
51+// separated, "" for the root). A nested matcher's patterns are
52+// evaluated against the path relative to the directory that owns them,
53+// matching git's own semantics.
54+type Stack struct {
55+ root string
56+ frames []stackFrame
57+}
58+
59+type stackFrame struct {
60+ relDir string // dir owning the .gitignore, relative to root, slash-sep
61+ ig *Gitignore
62+}
63+
64+// NewStack returns a Stack seeded with the root .gitignore (if any).
65+func NewStack(root string) *Stack {
66+ s := &Stack{root: root}
67+ s.frames = append(s.frames, stackFrame{relDir: "", ig: Load(root)})
68+ return s
69+}
70+
71+// Push loads the .gitignore in dir (an absolute path under root, with
72+// relDir its slash-separated path relative to root) and adds it to the
73+// stack. Call when the walk descends into a directory that is being
74+// kept; pair with Pop when leaving it. A directory with no .gitignore
75+// still gets a frame so the push/pop bookkeeping stays balanced.
76+func (s *Stack) Push(absDir, relDir string) {
77+ data, err := os.ReadFile(filepath.Join(absDir, ".gitignore"))
78+ var ig *Gitignore
79+ if err != nil {
80+ ig = &Gitignore{}
81+ } else {
82+ ig = Parse(string(data))
83+ }
84+ s.frames = append(s.frames, stackFrame{relDir: filepath.ToSlash(relDir), ig: ig})
85+}
86+
87+// Pop removes the most recently pushed frame. The seeded root frame is
88+// never popped.
89+func (s *Stack) Pop() {
90+ if len(s.frames) > 1 {
91+ s.frames = s.frames[:len(s.frames)-1]
92+ }
93+}
94+
95+// Match reports whether rel (slash-separated, relative to the walk
96+// root) should be ignored, consulting every .gitignore from the root
97+// down to the current directory. Each frame matches against rel made
98+// relative to the directory that owns it; a deeper frame's later rules
99+// win, mirroring git's nearest-file-wins ordering.
100+func (s *Stack) Match(rel string, isDir bool) bool {
101+ ignored := false
102+ for _, f := range s.frames {
103+ sub := rel
104+ if f.relDir != "" {
105+ prefix := f.relDir + "/"
106+ if !strings.HasPrefix(rel, prefix) {
107+ continue
108+ }
109+ sub = rel[len(prefix):]
110+ }
111+ if matched, neg := f.ig.matchResult(sub, isDir); matched {
112+ ignored = !neg
113+ }
114+ }
115+ return ignored
116+}
117+
40118 // Parse builds a matcher from raw .gitignore file contents.
41119 func Parse(data string) *Gitignore {
42120 g := &Gitignore{}
@@ -71,16 +149,26 @@ func Parse(data string) *Gitignore {
71149 // ignored. Later rules win, so a trailing negation can re-include a
72150 // previously ignored path.
73151 func (g *Gitignore) Match(rel string, isDir bool) bool {
74- ignored := false
152+ matched, negate := g.matchResult(rel, isDir)
153+ return matched && !negate
154+}
155+
156+// matchResult reports whether any rule matched rel and, if so, whether
157+// the winning (last) matching rule was a negation. This lets a Stack
158+// combine matchers across nested .gitignore files while still honoring
159+// negations correctly: a nested "!keep.me" must be able to re-include a
160+// path a parent .gitignore excluded.
161+func (g *Gitignore) matchResult(rel string, isDir bool) (matched, negate bool) {
75162 for _, r := range g.rules {
76163 if r.dirOnly && !isDir {
77164 continue
78165 }
79166 if r.matchPath(rel) {
80- ignored = !r.negate
167+ matched = true
168+ negate = r.negate
81169 }
82170 }
83- return ignored
171+ return matched, negate
84172 }
85173
86174 func (r rule) matchPath(rel string) bool {
@@ -37,6 +37,84 @@ func Load(root string) *Gitignore {
37 return Parse(string(data))37 return Parse(string(data))
38 }38 }
39 39
40+// Stack tracks the chain of .gitignore files from a walk's root down to
41+// the directory currently being visited. Real repositories routinely
42+// place a .gitignore inside a subdirectory (for example a vendored tool
43+// directory that ignores its own node_modules), and those nested rules
44+// are invisible to a single root-only matcher. During a recursive walk
45+// such an unhonored node_modules can swamp the entry budget before the
46+// walk ever reaches the files the user is actually looking for, so the
47+// nested files must be applied.
48+//
49+// Each frame holds the matcher parsed from a directory's .gitignore
50+// plus that directory's path relative to the walk root (slash
51+// separated, "" for the root). A nested matcher's patterns are
52+// evaluated against the path relative to the directory that owns them,
53+// matching git's own semantics.
54+type Stack struct {
55+ root string
56+ frames []stackFrame
57+}
58+
59+type stackFrame struct {
60+ relDir string // dir owning the .gitignore, relative to root, slash-sep
61+ ig *Gitignore
62+}
63+
64+// NewStack returns a Stack seeded with the root .gitignore (if any).
65+func NewStack(root string) *Stack {
66+ s := &Stack{root: root}
67+ s.frames = append(s.frames, stackFrame{relDir: "", ig: Load(root)})
68+ return s
69+}
70+
71+// Push loads the .gitignore in dir (an absolute path under root, with
72+// relDir its slash-separated path relative to root) and adds it to the
73+// stack. Call when the walk descends into a directory that is being
74+// kept; pair with Pop when leaving it. A directory with no .gitignore
75+// still gets a frame so the push/pop bookkeeping stays balanced.
76+func (s *Stack) Push(absDir, relDir string) {
77+ data, err := os.ReadFile(filepath.Join(absDir, ".gitignore"))
78+ var ig *Gitignore
79+ if err != nil {
80+ ig = &Gitignore{}
81+ } else {
82+ ig = Parse(string(data))
83+ }
84+ s.frames = append(s.frames, stackFrame{relDir: filepath.ToSlash(relDir), ig: ig})
85+}
86+
87+// Pop removes the most recently pushed frame. The seeded root frame is
88+// never popped.
89+func (s *Stack) Pop() {
90+ if len(s.frames) > 1 {
91+ s.frames = s.frames[:len(s.frames)-1]
92+ }
93+}
94+
95+// Match reports whether rel (slash-separated, relative to the walk
96+// root) should be ignored, consulting every .gitignore from the root
97+// down to the current directory. Each frame matches against rel made
98+// relative to the directory that owns it; a deeper frame's later rules
99+// win, mirroring git's nearest-file-wins ordering.
100+func (s *Stack) Match(rel string, isDir bool) bool {
101+ ignored := false
102+ for _, f := range s.frames {
103+ sub := rel
104+ if f.relDir != "" {
105+ prefix := f.relDir + "/"
106+ if !strings.HasPrefix(rel, prefix) {
107+ continue
108+ }
109+ sub = rel[len(prefix):]
110+ }
111+ if matched, neg := f.ig.matchResult(sub, isDir); matched {
112+ ignored = !neg
113+ }
114+ }
115+ return ignored
116+}
117+
40 // Parse builds a matcher from raw .gitignore file contents.118 // Parse builds a matcher from raw .gitignore file contents.
41 func Parse(data string) *Gitignore {119 func Parse(data string) *Gitignore {
42 g := &Gitignore{}120 g := &Gitignore{}
@@ -71,16 +149,26 @@ func Parse(data string) *Gitignore {
71 // ignored. Later rules win, so a trailing negation can re-include a149 // ignored. Later rules win, so a trailing negation can re-include a
72 // previously ignored path.150 // previously ignored path.
73 func (g *Gitignore) Match(rel string, isDir bool) bool {151 func (g *Gitignore) Match(rel string, isDir bool) bool {
74- ignored := false152+ matched, negate := g.matchResult(rel, isDir)
153+ return matched && !negate
154+}
155+
156+// matchResult reports whether any rule matched rel and, if so, whether
157+// the winning (last) matching rule was a negation. This lets a Stack
158+// combine matchers across nested .gitignore files while still honoring
159+// negations correctly: a nested "!keep.me" must be able to re-include a
160+// path a parent .gitignore excluded.
161+func (g *Gitignore) matchResult(rel string, isDir bool) (matched, negate bool) {
75 for _, r := range g.rules {162 for _, r := range g.rules {
76 if r.dirOnly && !isDir {163 if r.dirOnly && !isDir {
77 continue164 continue
78 }165 }
79 if r.matchPath(rel) {166 if r.matchPath(rel) {
80- ignored = !r.negate167+ matched = true
168+ negate = r.negate
81 }169 }
82 }170 }
83- return ignored171+ return matched, negate
84 }172 }
85 173
86 func (r rule) matchPath(rel string) bool {174 func (r rule) matchPath(rel string) bool {
M packages/ignore/gitignore_test.go +77 -1
@@ -1,6 +1,10 @@
11 package ignore
22
3-import "testing"
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+)
48
59 func TestParseAndMatch(t *testing.T) {
610 g := Parse(lines("# comment", "", ".terraform/", ".terragrunt-cache/", "node_modules/", "*.log", "/build", "!keep.log"))
@@ -40,6 +44,78 @@ func TestEmptyIgnoresNothing(t *testing.T) {
4044 }
4145 }
4246
47+// TestStackHonorsNestedGitignore pins the recursive-picker bug: a
48+// .gitignore living inside a subdirectory (here .opencode/.gitignore
49+// ignoring node_modules, exactly the layout that flooded the @-picker)
50+// must prune that subdirectory's node_modules even though the root
51+// .gitignore says nothing about it.
52+func TestStackHonorsNestedGitignore(t *testing.T) {
53+ root := t.TempDir()
54+ // Root .gitignore: only build/ at root, nothing about node_modules.
55+ if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte("build/\n"), 0o644); err != nil {
56+ t.Fatal(err)
57+ }
58+ opencode := filepath.Join(root, ".opencode")
59+ if err := os.MkdirAll(opencode, 0o755); err != nil {
60+ t.Fatal(err)
61+ }
62+ if err := os.WriteFile(filepath.Join(opencode, ".gitignore"), []byte("node_modules\n"), 0o644); err != nil {
63+ t.Fatal(err)
64+ }
65+
66+ s := NewStack(root)
67+ // Before descending into .opencode the nested rule is not in scope,
68+ // so a same-named path elsewhere stays visible.
69+ if s.Match("node_modules", true) {
70+ t.Fatal("root-level node_modules should not be ignored by an unloaded nested rule")
71+ }
72+ // Descend into .opencode: push its .gitignore.
73+ s.Push(opencode, ".opencode")
74+ if !s.Match(".opencode/node_modules", true) {
75+ t.Fatal("nested .opencode/.gitignore should ignore .opencode/node_modules")
76+ }
77+ if !s.Match(".opencode/node_modules/zod/src/v3/tests/pipeline.test.ts", false) {
78+ t.Fatal("files under nested-ignored node_modules should be ignored")
79+ }
80+ // A sibling source file inside .opencode is still visible.
81+ if s.Match(".opencode/config.json", false) {
82+ t.Fatal(".opencode/config.json should not be ignored")
83+ }
84+ // Root build/ rule still applies through the stack.
85+ if !s.Match("build", true) {
86+ t.Fatal("root build/ rule should still apply while nested frame is pushed")
87+ }
88+ // Pop the nested frame: its rule no longer applies.
89+ s.Pop()
90+ if s.Match(".opencode/node_modules", true) {
91+ t.Fatal("after popping, the nested rule should no longer be in scope")
92+ }
93+}
94+
95+// TestStackNestedNegationReincludes verifies a nested !pattern can
96+// re-include a path a parent .gitignore excluded.
97+func TestStackNestedNegationReincludes(t *testing.T) {
98+ root := t.TempDir()
99+ if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte("*.log\n"), 0o644); err != nil {
100+ t.Fatal(err)
101+ }
102+ sub := filepath.Join(root, "sub")
103+ if err := os.MkdirAll(sub, 0o755); err != nil {
104+ t.Fatal(err)
105+ }
106+ if err := os.WriteFile(filepath.Join(sub, ".gitignore"), []byte("!keep.log\n"), 0o644); err != nil {
107+ t.Fatal(err)
108+ }
109+ s := NewStack(root)
110+ s.Push(sub, "sub")
111+ if s.Match("sub/keep.log", false) {
112+ t.Fatal("nested !keep.log should re-include a *.log excluded by root")
113+ }
114+ if !s.Match("sub/other.log", false) {
115+ t.Fatal("sub/other.log should still be excluded by root *.log")
116+ }
117+}
118+
43119 // lines joins fixture lines with newlines for readable .gitignore
44120 // fixtures.
45121 func lines(ls ...string) string {
@@ -1,6 +1,10 @@
1 package ignore1 package ignore
2 2
3-import "testing"3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+)
4 8
5 func TestParseAndMatch(t *testing.T) {9 func TestParseAndMatch(t *testing.T) {
6 g := Parse(lines("# comment", "", ".terraform/", ".terragrunt-cache/", "node_modules/", "*.log", "/build", "!keep.log"))10 g := Parse(lines("# comment", "", ".terraform/", ".terragrunt-cache/", "node_modules/", "*.log", "/build", "!keep.log"))
@@ -40,6 +44,78 @@ func TestEmptyIgnoresNothing(t *testing.T) {
40 }44 }
41 }45 }
42 46
47+// TestStackHonorsNestedGitignore pins the recursive-picker bug: a
48+// .gitignore living inside a subdirectory (here .opencode/.gitignore
49+// ignoring node_modules, exactly the layout that flooded the @-picker)
50+// must prune that subdirectory's node_modules even though the root
51+// .gitignore says nothing about it.
52+func TestStackHonorsNestedGitignore(t *testing.T) {
53+ root := t.TempDir()
54+ // Root .gitignore: only build/ at root, nothing about node_modules.
55+ if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte("build/\n"), 0o644); err != nil {
56+ t.Fatal(err)
57+ }
58+ opencode := filepath.Join(root, ".opencode")
59+ if err := os.MkdirAll(opencode, 0o755); err != nil {
60+ t.Fatal(err)
61+ }
62+ if err := os.WriteFile(filepath.Join(opencode, ".gitignore"), []byte("node_modules\n"), 0o644); err != nil {
63+ t.Fatal(err)
64+ }
65+
66+ s := NewStack(root)
67+ // Before descending into .opencode the nested rule is not in scope,
68+ // so a same-named path elsewhere stays visible.
69+ if s.Match("node_modules", true) {
70+ t.Fatal("root-level node_modules should not be ignored by an unloaded nested rule")
71+ }
72+ // Descend into .opencode: push its .gitignore.
73+ s.Push(opencode, ".opencode")
74+ if !s.Match(".opencode/node_modules", true) {
75+ t.Fatal("nested .opencode/.gitignore should ignore .opencode/node_modules")
76+ }
77+ if !s.Match(".opencode/node_modules/zod/src/v3/tests/pipeline.test.ts", false) {
78+ t.Fatal("files under nested-ignored node_modules should be ignored")
79+ }
80+ // A sibling source file inside .opencode is still visible.
81+ if s.Match(".opencode/config.json", false) {
82+ t.Fatal(".opencode/config.json should not be ignored")
83+ }
84+ // Root build/ rule still applies through the stack.
85+ if !s.Match("build", true) {
86+ t.Fatal("root build/ rule should still apply while nested frame is pushed")
87+ }
88+ // Pop the nested frame: its rule no longer applies.
89+ s.Pop()
90+ if s.Match(".opencode/node_modules", true) {
91+ t.Fatal("after popping, the nested rule should no longer be in scope")
92+ }
93+}
94+
95+// TestStackNestedNegationReincludes verifies a nested !pattern can
96+// re-include a path a parent .gitignore excluded.
97+func TestStackNestedNegationReincludes(t *testing.T) {
98+ root := t.TempDir()
99+ if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte("*.log\n"), 0o644); err != nil {
100+ t.Fatal(err)
101+ }
102+ sub := filepath.Join(root, "sub")
103+ if err := os.MkdirAll(sub, 0o755); err != nil {
104+ t.Fatal(err)
105+ }
106+ if err := os.WriteFile(filepath.Join(sub, ".gitignore"), []byte("!keep.log\n"), 0o644); err != nil {
107+ t.Fatal(err)
108+ }
109+ s := NewStack(root)
110+ s.Push(sub, "sub")
111+ if s.Match("sub/keep.log", false) {
112+ t.Fatal("nested !keep.log should re-include a *.log excluded by root")
113+ }
114+ if !s.Match("sub/other.log", false) {
115+ t.Fatal("sub/other.log should still be excluded by root *.log")
116+ }
117+}
118+
43 // lines joins fixture lines with newlines for readable .gitignore119 // lines joins fixture lines with newlines for readable .gitignore
44 // fixtures.120 // fixtures.
45 func lines(ls ...string) string {121 func lines(ls ...string) string {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close