mirror of
https://github.com/patriceckhart/zot.git
synced 2026-07-01 15:53:46 +02:00
Merge pull request #25 from rgasper/feat/fuzzy-recursive-file-suggest
feat(tui): fuzzy @-file matching with toggleable recursive search
This commit is contained in:
commit
4c2e835f45
12 changed files with 676 additions and 111 deletions
4
go.mod
4
go.mod
|
|
@ -6,12 +6,14 @@ require (
|
|||
github.com/alecthomas/chroma/v2 v2.23.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/mattn/go-runewidth v0.0.16
|
||||
github.com/sahilm/fuzzy v0.1.1
|
||||
golang.org/x/image v0.18.0
|
||||
golang.org/x/sys v0.26.0
|
||||
golang.org/x/term v0.25.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
github.com/rivo/uniseg v0.2.0 // indirect
|
||||
golang.org/x/image v0.18.0 // indirect
|
||||
)
|
||||
|
|
|
|||
4
go.sum
4
go.sum
|
|
@ -10,10 +10,14 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
|
||||
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
||||
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
|
||||
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
|
||||
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||
|
|
|
|||
|
|
@ -944,6 +944,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
|
|||
Theme: theme,
|
||||
InlineImagesEnabled: initialCfg.InlineImagesEnabled,
|
||||
AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,
|
||||
RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
|
||||
RespectGitignore: initialCfg.RespectGitignore,
|
||||
ThemeName: initialCfg.Theme,
|
||||
ExtensionThemes: extMgr.ThemeOptions,
|
||||
AutoSwarmSystemAddendum: AutoSwarmSystemAddendum,
|
||||
|
|
|
|||
|
|
@ -32,6 +32,18 @@ type Config struct {
|
|||
// default; nil/missing means disabled. Toggle from /settings.
|
||||
AutoSwarmEnabled *bool `json:"auto_swarm_enabled,omitempty"`
|
||||
|
||||
// RecursiveFileSuggest controls the @-mention file picker. When true
|
||||
// the picker fuzzy-searches the whole project tree below the working
|
||||
// directory; nil/missing/false keeps the default directory-by-
|
||||
// directory browse. Toggle from /settings.
|
||||
RecursiveFileSuggest *bool `json:"recursive_file_suggest,omitempty"`
|
||||
|
||||
// RespectGitignore controls whether the @-mention file picker hides
|
||||
// files and directories matched by the project's root .gitignore (in
|
||||
// both flat and recursive modes). nil/missing means the default,
|
||||
// which is on; false shows ignored entries. Toggle from /settings.
|
||||
RespectGitignore *bool `json:"respect_gitignore,omitempty"`
|
||||
|
||||
// LastChangelogShown is the version whose release-notes
|
||||
// dialog the user has already seen. When the running binary's
|
||||
// version differs, the next interactive run shows the
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import (
|
|||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/patriceckhart/zot/packages/ignore"
|
||||
)
|
||||
|
||||
// runExtCommand dispatches `zot ext ...` subcommands. Returns
|
||||
|
|
@ -344,7 +346,7 @@ func copyDir(src, dst string) error {
|
|||
if info.IsDir() && name == ".git" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if ig.match(filepath.ToSlash(rel), info.IsDir()) {
|
||||
if ig.Match(filepath.ToSlash(rel), info.IsDir()) {
|
||||
if info.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
|
@ -370,98 +372,11 @@ func copyDir(src, dst string) error {
|
|||
})
|
||||
}
|
||||
|
||||
// gitignore is a minimal .gitignore matcher. It supports the common
|
||||
// patterns used in real extension repos: blank lines, comments (#),
|
||||
// negation (!), directory-only patterns (trailing /), anchored
|
||||
// patterns (leading /), and the * / ? / [..] wildcards via
|
||||
// filepath.Match. It intentionally does not implement ** globstar or
|
||||
// nested per-directory .gitignore files; the goal is to drop obvious
|
||||
// non-portable directories, not to be a faithful git reimplementation.
|
||||
type gitignore struct {
|
||||
rules []gitignoreRule
|
||||
}
|
||||
// gitignore matching lives in packages/ignore so the @-file picker in
|
||||
// packages/agent/modes can share it without an import cycle. These
|
||||
// thin aliases keep the existing call sites (and tests) terse.
|
||||
type gitignore = ignore.Gitignore
|
||||
|
||||
type gitignoreRule struct {
|
||||
pattern string
|
||||
negate bool
|
||||
dirOnly bool
|
||||
anchored bool
|
||||
}
|
||||
func loadGitignore(root string) *gitignore { return ignore.Load(root) }
|
||||
|
||||
func loadGitignore(root string) *gitignore {
|
||||
data, err := os.ReadFile(filepath.Join(root, ".gitignore"))
|
||||
if err != nil {
|
||||
return &gitignore{}
|
||||
}
|
||||
return loadGitignoreFromString(string(data))
|
||||
}
|
||||
|
||||
func loadGitignoreFromString(data string) *gitignore {
|
||||
g := &gitignore{}
|
||||
for _, line := range strings.Split(data, "\n") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
r := gitignoreRule{pattern: trimmed}
|
||||
if strings.HasPrefix(r.pattern, "!") {
|
||||
r.negate = true
|
||||
r.pattern = r.pattern[1:]
|
||||
}
|
||||
if strings.HasSuffix(r.pattern, "/") {
|
||||
r.dirOnly = true
|
||||
r.pattern = strings.TrimSuffix(r.pattern, "/")
|
||||
}
|
||||
if strings.HasPrefix(r.pattern, "/") {
|
||||
r.anchored = true
|
||||
r.pattern = strings.TrimPrefix(r.pattern, "/")
|
||||
}
|
||||
if r.pattern == "" {
|
||||
continue
|
||||
}
|
||||
g.rules = append(g.rules, r)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// match reports whether the slash-separated relative path should be
|
||||
// ignored. Later rules win, so a trailing negation can re-include a
|
||||
// previously ignored path.
|
||||
func (g *gitignore) match(rel string, isDir bool) bool {
|
||||
ignored := false
|
||||
for _, r := range g.rules {
|
||||
if r.dirOnly && !isDir {
|
||||
continue
|
||||
}
|
||||
if r.matchPath(rel) {
|
||||
ignored = !r.negate
|
||||
}
|
||||
}
|
||||
return ignored
|
||||
}
|
||||
|
||||
func (r gitignoreRule) matchPath(rel string) bool {
|
||||
if r.anchored || strings.Contains(r.pattern, "/") {
|
||||
if ok, _ := filepath.Match(r.pattern, rel); ok {
|
||||
return true
|
||||
}
|
||||
// Anchored directory pattern also matches everything beneath it.
|
||||
return strings.HasPrefix(rel, r.pattern+"/")
|
||||
}
|
||||
// Unanchored: match the basename of any path component.
|
||||
base := rel
|
||||
if i := strings.LastIndex(rel, "/"); i >= 0 {
|
||||
base = rel[i+1:]
|
||||
}
|
||||
if ok, _ := filepath.Match(r.pattern, base); ok {
|
||||
return true
|
||||
}
|
||||
// Match a directory component anywhere in the path.
|
||||
for _, part := range strings.Split(rel, "/") {
|
||||
if ok, _ := filepath.Match(r.pattern, part); ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func loadGitignoreFromString(data string) *gitignore { return ignore.Parse(data) }
|
||||
|
|
|
|||
|
|
@ -117,10 +117,10 @@ func TestCopyDirRespectsGitignore(t *testing.T) {
|
|||
|
||||
func TestGitignoreNegation(t *testing.T) {
|
||||
g := loadGitignoreFromString("build/\n!build/keep.txt\n")
|
||||
if !g.match("build", true) {
|
||||
if !g.Match("build", true) {
|
||||
t.Fatal("expected build/ dir to be ignored")
|
||||
}
|
||||
if g.match("build/keep.txt", false) {
|
||||
if g.Match("build/keep.txt", false) {
|
||||
t.Fatal("expected build/keep.txt to be re-included by negation")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,9 +8,26 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sahilm/fuzzy"
|
||||
|
||||
"github.com/patriceckhart/zot/packages/ignore"
|
||||
"github.com/patriceckhart/zot/packages/tui"
|
||||
)
|
||||
|
||||
// recursiveScanLimits bound the recursive walk so the picker stays
|
||||
// responsive in very large repos. Hitting either cap stops the walk
|
||||
// early; the entries gathered so far are still searchable.
|
||||
const (
|
||||
maxRecursiveEntries = 5000
|
||||
maxRecursiveDepth = 12
|
||||
)
|
||||
|
||||
// alwaysSkipDir is never descended into during a recursive scan,
|
||||
// regardless of .gitignore. .git is a repo-internal directory that
|
||||
// real projects rarely list in their own .gitignore yet never want
|
||||
// surfaced as an @-mention target.
|
||||
const alwaysSkipDir = ".git"
|
||||
|
||||
// fileSuggester provides an @-triggered file/directory picker popup.
|
||||
// Type "@" followed by an optional filter to list files in the working
|
||||
// directory. Arrow up/down navigate, enter selects, esc cancels.
|
||||
|
|
@ -20,8 +37,17 @@ type fileSuggester struct {
|
|||
lastMatches []fileEntry
|
||||
cwd string // project root
|
||||
browseRel string // relative path from cwd we're currently browsing ("" = cwd itself)
|
||||
cachedDir string // absolute directory we last scanned
|
||||
cachedAll []fileEntry
|
||||
// recursive enables a whole-tree fuzzy search instead of
|
||||
// directory-by-directory browsing. Mirrors the persisted
|
||||
// recursive_file_suggest setting; toggled live from /settings.
|
||||
recursive bool
|
||||
// respectGitignore drops entries matched by the project's root
|
||||
// .gitignore (and always .git) from both the flat and recursive
|
||||
// listings. Mirrors the persisted respect_gitignore setting; on by
|
||||
// default. Toggled live from /settings.
|
||||
respectGitignore bool
|
||||
cachedDir string // absolute directory we last scanned
|
||||
cachedAll []fileEntry
|
||||
// cachedMTime is the mtime of cachedDir at the time of the scan.
|
||||
// scan() compares the current mtime against this on every call and
|
||||
// re-reads the directory if it has changed, so files or folders
|
||||
|
|
@ -37,7 +63,10 @@ type fileEntry struct {
|
|||
isDir bool
|
||||
}
|
||||
|
||||
func newFileSuggester() *fileSuggester { return &fileSuggester{} }
|
||||
// newFileSuggester returns a picker that respects .gitignore by
|
||||
// default. Callers override via SetRespectGitignore once the persisted
|
||||
// setting is known.
|
||||
func newFileSuggester() *fileSuggester { return &fileSuggester{respectGitignore: true} }
|
||||
|
||||
// SetCWD updates the project root.
|
||||
func (s *fileSuggester) SetCWD(cwd string) {
|
||||
|
|
@ -49,6 +78,32 @@ func (s *fileSuggester) SetCWD(cwd string) {
|
|||
}
|
||||
}
|
||||
|
||||
// SetRecursive toggles whole-tree fuzzy search. Switching modes drops
|
||||
// the cache and resets the browse position so the next render reflects
|
||||
// the new mode immediately.
|
||||
func (s *fileSuggester) SetRecursive(on bool) {
|
||||
if s.recursive == on {
|
||||
return
|
||||
}
|
||||
s.recursive = on
|
||||
s.browseRel = ""
|
||||
s.cachedDir = ""
|
||||
s.cachedAll = nil
|
||||
s.cursor = 0
|
||||
}
|
||||
|
||||
// SetRespectGitignore toggles .gitignore filtering for both modes.
|
||||
// Switching drops the cache so the next scan reflects the new state.
|
||||
func (s *fileSuggester) SetRespectGitignore(on bool) {
|
||||
if s.respectGitignore == on {
|
||||
return
|
||||
}
|
||||
s.respectGitignore = on
|
||||
s.cachedDir = ""
|
||||
s.cachedAll = nil
|
||||
s.cursor = 0
|
||||
}
|
||||
|
||||
// browseDir returns the absolute directory currently being browsed.
|
||||
func (s *fileSuggester) browseDir() string {
|
||||
if s.browseRel == "" {
|
||||
|
|
@ -66,6 +121,9 @@ func (s *fileSuggester) browseDir() string {
|
|||
// supports). A failed stat falls through to a fresh ReadDir rather
|
||||
// than returning a stale cache so transient errors self-heal.
|
||||
func (s *fileSuggester) scan() []fileEntry {
|
||||
if s.recursive {
|
||||
return s.scanRecursive()
|
||||
}
|
||||
dir := s.browseDir()
|
||||
var mtime time.Time
|
||||
if info, err := os.Stat(dir); err == nil {
|
||||
|
|
@ -78,6 +136,10 @@ func (s *fileSuggester) scan() []fileEntry {
|
|||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var ig *ignore.Gitignore
|
||||
if s.respectGitignore {
|
||||
ig = ignore.Load(s.cwd)
|
||||
}
|
||||
var all []fileEntry
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
|
|
@ -85,6 +147,14 @@ func (s *fileSuggester) scan() []fileEntry {
|
|||
if s.browseRel != "" {
|
||||
rel = filepath.Join(s.browseRel, name)
|
||||
}
|
||||
if s.respectGitignore {
|
||||
if e.IsDir() && name == alwaysSkipDir {
|
||||
continue
|
||||
}
|
||||
if ig.Match(filepath.ToSlash(rel), e.IsDir()) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
all = append(all, fileEntry{
|
||||
name: name,
|
||||
rel: rel,
|
||||
|
|
@ -103,6 +173,85 @@ func (s *fileSuggester) scan() []fileEntry {
|
|||
return all
|
||||
}
|
||||
|
||||
// scanRecursive walks the whole project tree below cwd and returns
|
||||
// every file and directory as a fileEntry whose rel is the path
|
||||
// relative to cwd. The walk honors the project's root .gitignore (so
|
||||
// build outputs, dependency directories, and tool caches like
|
||||
// .terraform/.terragrunt-cache stay out of the picker) plus an
|
||||
// unconditional .git skip, and stops once it hits the entry/depth caps.
|
||||
//
|
||||
// Results are cached by cwd + mtime of cwd. Unlike the flat scan a
|
||||
// single mtime can't catch every nested change, so the cache is best
|
||||
// effort; Invalidate() (called on each keystroke path that matters)
|
||||
// and the explicit cache drops on toggle keep it fresh enough for an
|
||||
// interactive picker.
|
||||
func (s *fileSuggester) scanRecursive() []fileEntry {
|
||||
root := s.cwd
|
||||
var mtime time.Time
|
||||
if info, err := os.Stat(root); err == nil {
|
||||
mtime = info.ModTime()
|
||||
}
|
||||
if s.cachedDir == root && s.cachedAll != nil && !mtime.IsZero() && mtime.Equal(s.cachedMTime) {
|
||||
return s.cachedAll
|
||||
}
|
||||
|
||||
var ig *ignore.Gitignore
|
||||
if s.respectGitignore {
|
||||
ig = ignore.Load(root)
|
||||
}
|
||||
var all []fileEntry
|
||||
rootSep := strings.Count(root, string(os.PathSeparator))
|
||||
_ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
if d != nil && d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if path == root {
|
||||
return nil
|
||||
}
|
||||
rel, relErr := filepath.Rel(root, path)
|
||||
if relErr != nil {
|
||||
return nil
|
||||
}
|
||||
if s.respectGitignore {
|
||||
// .gitignore patterns are matched against slash-separated paths.
|
||||
if ig.Match(filepath.ToSlash(rel), d.IsDir()) {
|
||||
if d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if d.IsDir() {
|
||||
// .git is always pruned: it's never a useful @-mention target
|
||||
// and would otherwise blow the entry budget even when
|
||||
// gitignore filtering is off.
|
||||
if d.Name() == alwaysSkipDir {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if strings.Count(path, string(os.PathSeparator))-rootSep >= maxRecursiveDepth {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
}
|
||||
all = append(all, fileEntry{
|
||||
name: rel,
|
||||
rel: rel,
|
||||
isDir: d.IsDir(),
|
||||
})
|
||||
if len(all) >= maxRecursiveEntries {
|
||||
return filepath.SkipAll
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
s.cachedAll = all
|
||||
s.cachedDir = root
|
||||
s.cachedMTime = mtime
|
||||
return all
|
||||
}
|
||||
|
||||
// extractAtQuery returns the filter string after "@".
|
||||
func extractAtQuery(input string) (string, bool) {
|
||||
input = strings.TrimRight(input, " ")
|
||||
|
|
@ -121,6 +270,11 @@ func extractAtQuery(input string) (string, bool) {
|
|||
}
|
||||
|
||||
// matches returns file entries matching the current @-query.
|
||||
//
|
||||
// An empty query returns every entry in scan order. A non-empty query
|
||||
// is ranked with sahilm/fuzzy: in recursive mode the pattern is matched
|
||||
// against each entry's path relative to cwd (so "foobar" can find
|
||||
// "src/foo/bar.go"); in flat mode it matches the entry's display name.
|
||||
func (s *fileSuggester) matches(input string) []fileEntry {
|
||||
query, ok := extractAtQuery(input)
|
||||
if !ok {
|
||||
|
|
@ -130,14 +284,22 @@ func (s *fileSuggester) matches(input string) []fileEntry {
|
|||
if len(all) == 0 {
|
||||
return nil
|
||||
}
|
||||
needle := strings.ToLower(query)
|
||||
if needle == "" {
|
||||
if query == "" {
|
||||
return all
|
||||
}
|
||||
var out []fileEntry
|
||||
for _, e := range all {
|
||||
if strings.Contains(strings.ToLower(e.name), needle) {
|
||||
out = append(out, e)
|
||||
haystack := make([]string, len(all))
|
||||
for i, e := range all {
|
||||
if s.recursive {
|
||||
haystack[i] = e.rel
|
||||
} else {
|
||||
haystack[i] = e.name
|
||||
}
|
||||
}
|
||||
ranked := fuzzy.Find(query, haystack)
|
||||
out := make([]fileEntry, 0, len(ranked))
|
||||
for _, m := range ranked {
|
||||
if m.Index >= 0 && m.Index < len(all) {
|
||||
out = append(out, all[m.Index])
|
||||
}
|
||||
}
|
||||
return out
|
||||
|
|
@ -176,8 +338,12 @@ func (s *fileSuggester) Down() {
|
|||
}
|
||||
|
||||
// Right opens the selected directory, descending into it.
|
||||
// Returns true if a directory was entered.
|
||||
// Returns true if a directory was entered. Disabled in recursive mode,
|
||||
// where the whole tree is already flattened into the result list.
|
||||
func (s *fileSuggester) Right() bool {
|
||||
if s.recursive {
|
||||
return false
|
||||
}
|
||||
m := s.lastMatches
|
||||
if len(m) == 0 || s.cursor >= len(m) {
|
||||
return false
|
||||
|
|
@ -194,8 +360,11 @@ func (s *fileSuggester) Right() bool {
|
|||
}
|
||||
|
||||
// Left goes back to the parent directory.
|
||||
// Returns true if we moved up.
|
||||
// Returns true if we moved up. Disabled in recursive mode.
|
||||
func (s *fileSuggester) Left() bool {
|
||||
if s.recursive {
|
||||
return false
|
||||
}
|
||||
if s.browseRel == "" {
|
||||
return false
|
||||
}
|
||||
|
|
@ -283,10 +452,13 @@ func (s *fileSuggester) Render(input string, th tui.Theme, width int) []string {
|
|||
}
|
||||
|
||||
out = append(out, "")
|
||||
hint := " \u2191/\u2193 navigate - enter select - esc cancel"
|
||||
if s.browseRel != "" {
|
||||
var hint string
|
||||
switch {
|
||||
case s.recursive:
|
||||
hint = " \u2191/\u2193 navigate - enter select - esc cancel (recursive)"
|
||||
case s.browseRel != "":
|
||||
hint = " \u2191/\u2193 navigate - \u2192 open - \u2190 back - enter select - esc cancel"
|
||||
} else {
|
||||
default:
|
||||
hint = " \u2191/\u2193 navigate - \u2192 open dir - enter select - esc cancel"
|
||||
}
|
||||
out = append(out, th.FG256(th.Muted, hint))
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
|
@ -97,3 +98,216 @@ func (b byDirsFirst) Less(i, j int) bool {
|
|||
}
|
||||
return b[i].name < b[j].name
|
||||
}
|
||||
|
||||
// TestFileSuggesterFuzzyMatch verifies the @-query ranks entries with
|
||||
// a fuzzy subsequence match rather than a plain substring, so a
|
||||
// non-contiguous pattern like "fsg" still finds "file_suggest.go".
|
||||
func TestFileSuggesterFuzzyMatch(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
for _, name := range []string{"file_suggest.go", "interactive.go", "README.md"} {
|
||||
if err := os.WriteFile(filepath.Join(tmp, name), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
s := newFileSuggester()
|
||||
s.SetCWD(tmp)
|
||||
|
||||
got := s.matches("@fsg")
|
||||
if !containsEntry(got, "file_suggest.go", false) {
|
||||
t.Fatalf("fuzzy query @fsg did not match file_suggest.go: %#v", got)
|
||||
}
|
||||
if len(got) == 0 || got[0].name != "file_suggest.go" {
|
||||
t.Fatalf("file_suggest.go not ranked first for @fsg: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileSuggesterRecursiveMatch verifies recursive mode flattens the
|
||||
// tree and matches against the cwd-relative path, so a pattern can
|
||||
// span directory boundaries.
|
||||
func TestFileSuggesterRecursiveMatch(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(tmp, "src", "foo"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tmp, "src", "foo", "bar.go"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := newFileSuggester()
|
||||
s.SetCWD(tmp)
|
||||
s.SetRecursive(true)
|
||||
|
||||
rel := filepath.Join("src", "foo", "bar.go")
|
||||
got := s.matches("@foobar")
|
||||
if !containsEntry(got, rel, false) {
|
||||
t.Fatalf("recursive @foobar did not match %s: %#v", rel, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileSuggesterRecursiveSkipsHeavyDirs ensures the walk prunes
|
||||
// directories like .git that would otherwise dominate the budget.
|
||||
func TestFileSuggesterRecursiveSkipsHeavyDirs(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(tmp, ".git", "objects"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tmp, ".git", "objects", "deadbeef"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tmp, "main.go"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := newFileSuggester()
|
||||
s.SetCWD(tmp)
|
||||
s.SetRecursive(true)
|
||||
|
||||
all := s.scan()
|
||||
for _, e := range all {
|
||||
if e.rel == ".git" || strings.HasPrefix(e.rel, ".git"+string(filepath.Separator)) {
|
||||
t.Fatalf("recursive scan descended into .git: %#v", e)
|
||||
}
|
||||
}
|
||||
if !containsEntry(all, "main.go", false) {
|
||||
t.Fatalf("recursive scan missing main.go: %#v", all)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileSuggesterRecursiveHonorsGitignore ensures the recursive walk
|
||||
// prunes anything listed in the project's root .gitignore — build
|
||||
// outputs, dependency dirs, and IaC tool caches like
|
||||
// .terraform/.terragrunt-cache — while still surfacing tracked files.
|
||||
func TestFileSuggesterRecursiveHonorsGitignore(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(tmp, ".gitignore"),
|
||||
[]byte(".terraform/\n.terragrunt-cache/\nnode_modules/\n*.log\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ignored := []string{".terraform", ".terragrunt-cache", "node_modules"}
|
||||
for _, dir := range ignored {
|
||||
nested := filepath.Join(tmp, dir, "deep")
|
||||
if err := os.MkdirAll(nested, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(nested, "junk"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tmp, "debug.log"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tmp, "main.tf"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := newFileSuggester()
|
||||
s.SetCWD(tmp)
|
||||
s.SetRecursive(true)
|
||||
|
||||
all := s.scan()
|
||||
for _, e := range all {
|
||||
for _, skip := range ignored {
|
||||
if e.rel == skip || strings.HasPrefix(e.rel, skip+string(filepath.Separator)) {
|
||||
t.Fatalf("recursive scan descended into gitignored %s: %#v", skip, e)
|
||||
}
|
||||
}
|
||||
if e.rel == "debug.log" {
|
||||
t.Fatalf("recursive scan surfaced gitignored *.log file: %#v", e)
|
||||
}
|
||||
}
|
||||
if !containsEntry(all, "main.tf", false) {
|
||||
t.Fatalf("recursive scan missing tracked main.tf: %#v", all)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileSuggesterFlatModeHonorsGitignore verifies the default
|
||||
// directory-by-directory browse also hides gitignored entries and
|
||||
// always hides .git.
|
||||
func TestFileSuggesterFlatModeHonorsGitignore(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(tmp, ".gitignore"), []byte("node_modules/\n*.log\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(tmp, "node_modules"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(tmp, ".git"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tmp, "debug.log"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tmp, "main.go"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := newFileSuggester()
|
||||
s.SetCWD(tmp) // flat mode, respectGitignore on by default
|
||||
|
||||
all := s.scan()
|
||||
if containsEntry(all, "node_modules", true) {
|
||||
t.Fatal("flat scan surfaced gitignored node_modules/")
|
||||
}
|
||||
if containsEntry(all, ".git", true) {
|
||||
t.Fatal("flat scan surfaced .git/")
|
||||
}
|
||||
if containsEntry(all, "debug.log", false) {
|
||||
t.Fatal("flat scan surfaced gitignored *.log file")
|
||||
}
|
||||
if !containsEntry(all, "main.go", false) {
|
||||
t.Fatalf("flat scan missing tracked main.go: %#v", all)
|
||||
}
|
||||
// .gitignore itself is not ignored, so it should remain visible.
|
||||
if !containsEntry(all, ".gitignore", false) {
|
||||
t.Fatalf("flat scan missing .gitignore: %#v", all)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileSuggesterRespectGitignoreToggle verifies disabling the
|
||||
// setting surfaces gitignored entries in both modes (while .git stays
|
||||
// hidden in recursive mode to protect the entry budget).
|
||||
func TestFileSuggesterRespectGitignoreToggle(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(tmp, ".gitignore"), []byte("dist/\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(tmp, "dist"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := newFileSuggester()
|
||||
s.SetCWD(tmp)
|
||||
|
||||
// On by default: dist/ hidden.
|
||||
if containsEntry(s.scan(), "dist", true) {
|
||||
t.Fatal("dist/ should be hidden while respectGitignore is on")
|
||||
}
|
||||
// Toggle off: dist/ visible (flat mode).
|
||||
s.SetRespectGitignore(false)
|
||||
if !containsEntry(s.scan(), "dist", true) {
|
||||
t.Fatal("dist/ should be visible after disabling respectGitignore (flat)")
|
||||
}
|
||||
// And in recursive mode.
|
||||
s.SetRecursive(true)
|
||||
if !containsEntry(s.scan(), "dist", true) {
|
||||
t.Fatal("dist/ should be visible after disabling respectGitignore (recursive)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileSuggesterToggleResetsCache verifies SetRecursive drops the
|
||||
// cached scan so the next matches() reflects the new mode.
|
||||
func TestFileSuggesterToggleResetsCache(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(tmp, "pkg"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tmp, "pkg", "nested.go"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := newFileSuggester()
|
||||
s.SetCWD(tmp)
|
||||
|
||||
rel := filepath.Join("pkg", "nested.go")
|
||||
if containsEntry(s.matches("@nested"), rel, false) {
|
||||
t.Fatal("flat mode unexpectedly saw nested.go")
|
||||
}
|
||||
s.SetRecursive(true)
|
||||
if !containsEntry(s.matches("@nested"), rel, false) {
|
||||
t.Fatal("recursive mode did not surface nested.go after toggle")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,16 @@ type InteractiveConfig struct {
|
|||
// re-reading config.json on every open.
|
||||
AutoSwarmEnabled *bool
|
||||
|
||||
// RecursiveFileSuggest mirrors the persisted recursive_file_suggest
|
||||
// flag at startup. When true the @-mention picker fuzzy-searches the
|
||||
// whole project tree instead of browsing one directory at a time.
|
||||
RecursiveFileSuggest *bool
|
||||
|
||||
// RespectGitignore mirrors the persisted respect_gitignore flag at
|
||||
// startup. nil means the default (on); when false the @-mention
|
||||
// picker shows files matched by the project's root .gitignore.
|
||||
RespectGitignore *bool
|
||||
|
||||
// ThemeName mirrors the persisted config theme value. Empty means auto.
|
||||
ThemeName string
|
||||
// ExtensionThemes returns themes bundled with loaded extensions.
|
||||
|
|
@ -228,6 +238,8 @@ type chatCacheKey struct {
|
|||
type SettingsStore interface {
|
||||
SetInlineImages(enabled bool) error
|
||||
SetAutoSwarm(enabled bool) error
|
||||
SetRecursiveFileSuggest(enabled bool) error
|
||||
SetRespectGitignore(enabled bool) error
|
||||
SetReasoning(level string) error
|
||||
SetTheme(name string) error
|
||||
}
|
||||
|
|
@ -474,6 +486,8 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
|
|||
spin: newSpinner(cfg.Theme),
|
||||
inputHistoryIndex: -1,
|
||||
}
|
||||
i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest)
|
||||
i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore)
|
||||
if cfg.Agent != nil {
|
||||
i.agent = cfg.Agent
|
||||
i.view.Messages = cfg.Agent.Messages()
|
||||
|
|
@ -2622,6 +2636,9 @@ func (i *Interactive) openSettingsDialog() {
|
|||
autoSwarmHint = "swarm supervisor not available in this mode"
|
||||
}
|
||||
|
||||
recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest
|
||||
respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore
|
||||
|
||||
reasoningOptions := []settingsOption{
|
||||
{value: "", label: "off", desc: "no reasoning"},
|
||||
{value: "minimum", label: "minimum", desc: "very brief (~1k tokens)"},
|
||||
|
|
@ -2685,6 +2702,18 @@ func (i *Interactive) openSettingsDialog() {
|
|||
disabled: autoSwarmDisabled,
|
||||
hint: autoSwarmHint,
|
||||
},
|
||||
{
|
||||
key: "recursive_file_suggest",
|
||||
label: "recursive @-file search",
|
||||
desc: "fuzzy-search the whole project tree when picking files with @ instead of browsing one directory at a time",
|
||||
value: recursiveFiles,
|
||||
},
|
||||
{
|
||||
key: "respect_gitignore",
|
||||
label: "hide gitignored files in @-picker",
|
||||
desc: "skip files and directories matched by the project's root .gitignore (and .git) when picking files with @",
|
||||
value: respectGitignore,
|
||||
},
|
||||
{
|
||||
key: "reasoning",
|
||||
label: "thinking level",
|
||||
|
|
@ -2767,6 +2796,40 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
|
|||
i.statusOK = "auto-swarm " + onOff(value)
|
||||
i.statusErr = ""
|
||||
i.mu.Unlock()
|
||||
case "recursive_file_suggest":
|
||||
val := value
|
||||
i.cfg.RecursiveFileSuggest = &val
|
||||
if i.cfg.SettingsStore != nil {
|
||||
if err := i.cfg.SettingsStore.SetRecursiveFileSuggest(value); err != nil {
|
||||
i.mu.Lock()
|
||||
i.statusErr = "settings: " + err.Error()
|
||||
i.mu.Unlock()
|
||||
return
|
||||
}
|
||||
}
|
||||
// Flip the live picker so the next @ reflects the new mode
|
||||
// without restarting zot. SetRecursive drops its cache.
|
||||
i.fileSuggest.SetRecursive(value)
|
||||
i.mu.Lock()
|
||||
i.statusOK = "recursive @-file search " + onOff(value)
|
||||
i.statusErr = ""
|
||||
i.mu.Unlock()
|
||||
case "respect_gitignore":
|
||||
val := value
|
||||
i.cfg.RespectGitignore = &val
|
||||
if i.cfg.SettingsStore != nil {
|
||||
if err := i.cfg.SettingsStore.SetRespectGitignore(value); err != nil {
|
||||
i.mu.Lock()
|
||||
i.statusErr = "settings: " + err.Error()
|
||||
i.mu.Unlock()
|
||||
return
|
||||
}
|
||||
}
|
||||
i.fileSuggest.SetRespectGitignore(value)
|
||||
i.mu.Lock()
|
||||
i.statusOK = "hide gitignored files in @-picker " + onOff(value)
|
||||
i.statusErr = ""
|
||||
i.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,24 @@ func (configSettingsStore) SetAutoSwarm(enabled bool) error {
|
|||
return SaveConfig(cfg)
|
||||
}
|
||||
|
||||
func (configSettingsStore) SetRecursiveFileSuggest(enabled bool) error {
|
||||
cfg, err := LoadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.RecursiveFileSuggest = &enabled
|
||||
return SaveConfig(cfg)
|
||||
}
|
||||
|
||||
func (configSettingsStore) SetRespectGitignore(enabled bool) error {
|
||||
cfg, err := LoadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.RespectGitignore = &enabled
|
||||
return SaveConfig(cfg)
|
||||
}
|
||||
|
||||
func (configSettingsStore) SetReasoning(level string) error {
|
||||
cfg, err := LoadConfig()
|
||||
if err != nil {
|
||||
|
|
|
|||
109
packages/ignore/gitignore.go
Normal file
109
packages/ignore/gitignore.go
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
// Package ignore provides a minimal .gitignore matcher shared across
|
||||
// zot. It is intentionally small: enough to drop obvious non-source
|
||||
// directories (build outputs, dependency and tool caches) from
|
||||
// recursive walks, not a faithful git reimplementation.
|
||||
package ignore
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Gitignore is a minimal .gitignore matcher. It supports the common
|
||||
// patterns used in real repos: blank lines, comments (#), negation (!),
|
||||
// directory-only patterns (trailing /), anchored patterns (leading /),
|
||||
// and the * / ? / [..] wildcards via filepath.Match. It intentionally
|
||||
// does not implement ** globstar or nested per-directory .gitignore
|
||||
// files.
|
||||
type Gitignore struct {
|
||||
rules []rule
|
||||
}
|
||||
|
||||
type rule struct {
|
||||
pattern string
|
||||
negate bool
|
||||
dirOnly bool
|
||||
anchored bool
|
||||
}
|
||||
|
||||
// Load reads the .gitignore at the root directory. A missing or
|
||||
// unreadable file yields an empty matcher that ignores nothing.
|
||||
func Load(root string) *Gitignore {
|
||||
data, err := os.ReadFile(filepath.Join(root, ".gitignore"))
|
||||
if err != nil {
|
||||
return &Gitignore{}
|
||||
}
|
||||
return Parse(string(data))
|
||||
}
|
||||
|
||||
// Parse builds a matcher from raw .gitignore file contents.
|
||||
func Parse(data string) *Gitignore {
|
||||
g := &Gitignore{}
|
||||
for _, line := range strings.Split(data, "\n") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
r := rule{pattern: trimmed}
|
||||
if strings.HasPrefix(r.pattern, "!") {
|
||||
r.negate = true
|
||||
r.pattern = r.pattern[1:]
|
||||
}
|
||||
if strings.HasSuffix(r.pattern, "/") {
|
||||
r.dirOnly = true
|
||||
r.pattern = strings.TrimSuffix(r.pattern, "/")
|
||||
}
|
||||
if strings.HasPrefix(r.pattern, "/") {
|
||||
r.anchored = true
|
||||
r.pattern = strings.TrimPrefix(r.pattern, "/")
|
||||
}
|
||||
if r.pattern == "" {
|
||||
continue
|
||||
}
|
||||
g.rules = append(g.rules, r)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// Match reports whether the slash-separated relative path should be
|
||||
// ignored. Later rules win, so a trailing negation can re-include a
|
||||
// previously ignored path.
|
||||
func (g *Gitignore) Match(rel string, isDir bool) bool {
|
||||
ignored := false
|
||||
for _, r := range g.rules {
|
||||
if r.dirOnly && !isDir {
|
||||
continue
|
||||
}
|
||||
if r.matchPath(rel) {
|
||||
ignored = !r.negate
|
||||
}
|
||||
}
|
||||
return ignored
|
||||
}
|
||||
|
||||
func (r rule) matchPath(rel string) bool {
|
||||
if r.anchored || strings.Contains(r.pattern, "/") {
|
||||
if ok, _ := filepath.Match(r.pattern, rel); ok {
|
||||
return true
|
||||
}
|
||||
// Anchored directory pattern also matches everything beneath it.
|
||||
return strings.HasPrefix(rel, r.pattern+"/")
|
||||
}
|
||||
// Unanchored: match the basename of any path component.
|
||||
base := rel
|
||||
if i := strings.LastIndex(rel, "/"); i >= 0 {
|
||||
base = rel[i+1:]
|
||||
}
|
||||
if ok, _ := filepath.Match(r.pattern, base); ok {
|
||||
return true
|
||||
}
|
||||
// Match a directory component anywhere in the path.
|
||||
for _, part := range strings.Split(rel, "/") {
|
||||
if ok, _ := filepath.Match(r.pattern, part); ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
54
packages/ignore/gitignore_test.go
Normal file
54
packages/ignore/gitignore_test.go
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
package ignore
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseAndMatch(t *testing.T) {
|
||||
g := Parse(lines("# comment", "", ".terraform/", ".terragrunt-cache/", "node_modules/", "*.log", "/build", "!keep.log"))
|
||||
|
||||
cases := []struct {
|
||||
rel string
|
||||
isDir bool
|
||||
want bool
|
||||
}{
|
||||
{".terraform", true, true},
|
||||
// A dirOnly rule matches the directory itself; the walk prunes
|
||||
// descent on that match, so children are never tested. A file
|
||||
// path under it is therefore not matched directly by the rule.
|
||||
{".terraform/providers/x", false, false},
|
||||
{".terragrunt-cache", true, true},
|
||||
{"modules/.terragrunt-cache", true, true},
|
||||
{"node_modules", true, true},
|
||||
{"src/node_modules/pkg", true, true},
|
||||
{"debug.log", false, true},
|
||||
{"keep.log", false, false}, // re-included by negation
|
||||
{"build", true, true}, // anchored
|
||||
{"sub/build", true, false}, // anchored: only at root
|
||||
{"main.tf", false, false},
|
||||
{"src/app.go", false, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := g.Match(c.rel, c.isDir); got != c.want {
|
||||
t.Errorf("Match(%q, dir=%v) = %v, want %v", c.rel, c.isDir, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyIgnoresNothing(t *testing.T) {
|
||||
g := Parse("")
|
||||
if g.Match("anything", false) || g.Match("dir", true) {
|
||||
t.Fatal("empty matcher should ignore nothing")
|
||||
}
|
||||
}
|
||||
|
||||
// lines joins fixture lines with newlines for readable .gitignore
|
||||
// fixtures.
|
||||
func lines(ls ...string) string {
|
||||
out := ""
|
||||
for i, l := range ls {
|
||||
if i > 0 {
|
||||
out += "\n"
|
||||
}
|
||||
out += l
|
||||
}
|
||||
return out
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue