From dee7b7642cfd71f41f8ff9901bb6f6c16ae926a9 Mon Sep 17 00:00:00 2001 From: NiloCK Date: Mon, 1 Jun 2026 09:52:17 -0300 Subject: [PATCH] add `exclude` config --- readme.md | 10 ++++ tui/config.go | 7 +++ tui/getfiles_test.go | 113 +++++++++++++++++++++++++++++++++++++++++++ tui/tui.go | 60 +++++++++++++++++++---- 4 files changed, 180 insertions(+), 10 deletions(-) create mode 100644 tui/getfiles_test.go diff --git a/readme.md b/readme.md index edd2634..32dac78 100644 --- a/readme.md +++ b/readme.md @@ -116,6 +116,16 @@ Include a `.tuido` file in individual directories to add filetypes for parsing a extensions=go,js,cpp ``` +To exclude directories or files from traversal, use glob patterns (note: `.gitignore` patterns are also respected automatically): + +``` +exclude=node_modules,vendor,dist +``` + +``` +exclude=*.gen.go,*.min.js +``` + To configure the friction threshold that triggers the deterrence nag when adding items: ``` diff --git a/tui/config.go b/tui/config.go index 8ec7e33..0c75576 100644 --- a/tui/config.go +++ b/tui/config.go @@ -25,6 +25,10 @@ type config struct { // frictionThreshold is the number of items that can be displayed or added to // before a nag deterrent is displayed. frictionThreshold int + + // exclude is a list of directory or file name globs to skip during traversal. + // Matched against each path component (e.g. "node_modules", "vendor", "*.gen.go"). + exclude []string } func (cfg config) String() string { @@ -86,6 +90,9 @@ func parseConfig(file *os.File) config { cfg.frictionThreshold = n } } + if split[0] == "exclude" { + cfg.exclude = strings.Split(split[1], ",") + } } else { // not a config line: diff --git a/tui/getfiles_test.go b/tui/getfiles_test.go new file mode 100644 index 0000000..fc0305f --- /dev/null +++ b/tui/getfiles_test.go @@ -0,0 +1,113 @@ +package tui + +import ( + "os" + "path/filepath" + "testing" +) + +func containsPath(paths []string, target string) bool { + for _, p := range paths { + if p == target { + return true + } + } + return false +} + +func writeTempFile(t *testing.T, dir, name, content string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("writeTempFile %s: %v", path, err) + } + return path +} + +func TestGetFiles_readsItemWithNoConfig(t *testing.T) { + dir := t.TempDir() + want := writeTempFile(t, dir, "ignoreme.xit", "[ ] a task\n") + + got := getFiles(dir, []string{"xit"}, nil) + + if !containsPath(got, want) { + t.Errorf("expected %s in results, got %v", want, got) + } +} + +func TestGetFiles_excludeFile(t *testing.T) { + dir := t.TempDir() + excluded := writeTempFile(t, dir, "ignoreme.xit", "[ ] a task\n") + kept := writeTempFile(t, dir, "keepme.xit", "[ ] another task\n") + + got := getFiles(dir, []string{"xit"}, []string{"ignoreme.xit"}) + + if containsPath(got, excluded) { + t.Errorf("expected %s to be excluded, got %v", excluded, got) + } + if !containsPath(got, kept) { + t.Errorf("expected %s to be kept, got %v", kept, got) + } +} + +func TestGetFiles_excludeDir(t *testing.T) { + dir := t.TempDir() + subdir := filepath.Join(dir, "ignorethisdir") + if err := os.Mkdir(subdir, 0755); err != nil { + t.Fatal(err) + } + excluded := writeTempFile(t, subdir, "item.xit", "[ ] excluded task\n") + kept := writeTempFile(t, dir, "keepme.xit", "[ ] kept task\n") + + got := getFiles(dir, []string{"xit"}, []string{"ignorethisdir"}) + + if containsPath(got, excluded) { + t.Errorf("expected %s to be excluded, got %v", excluded, got) + } + if !containsPath(got, kept) { + t.Errorf("expected %s to be kept, got %v", kept, got) + } +} + +func TestGetFiles_excludeGlob(t *testing.T) { + dir := t.TempDir() + excluded1 := writeTempFile(t, dir, "ignore_this.xit", "[ ] task one\n") + excluded2 := writeTempFile(t, dir, "ignore_that.xit", "[ ] task two\n") + kept := writeTempFile(t, dir, "keepme.xit", "[ ] kept task\n") + + got := getFiles(dir, []string{"xit"}, []string{"ignore_*"}) + + for _, ex := range []string{excluded1, excluded2} { + if containsPath(got, ex) { + t.Errorf("expected %s to be excluded by glob, got %v", ex, got) + } + } + if !containsPath(got, kept) { + t.Errorf("expected %s to be kept, got %v", kept, got) + } +} + +func TestGetFiles_excludeViaSubdirConfig(t *testing.T) { + dir := t.TempDir() + subdir := filepath.Join(dir, "sub") + if err := os.Mkdir(subdir, 0755); err != nil { + t.Fatal(err) + } + ignoredDir := filepath.Join(subdir, "ignorethisdir") + if err := os.Mkdir(ignoredDir, 0755); err != nil { + t.Fatal(err) + } + + writeTempFile(t, subdir, ".tuido", "exclude=ignorethisdir\n") + excluded := writeTempFile(t, ignoredDir, "item.xit", "[ ] excluded task\n") + kept := writeTempFile(t, subdir, "keepme.xit", "[ ] kept task\n") + + got := getFiles(dir, []string{"xit"}, nil) + + if containsPath(got, excluded) { + t.Errorf("expected %s to be excluded via .tuido config, got %v", excluded, got) + } + if !containsPath(got, kept) { + t.Errorf("expected %s to be kept, got %v", kept, got) + } +} diff --git a/tui/tui.go b/tui/tui.go index 5f2d1e5..1c78971 100644 --- a/tui/tui.go +++ b/tui/tui.go @@ -475,35 +475,75 @@ func GetItems(file string) []*tuido.Item { } func GetFiles(wd string, extensions []string) []string { + return getFiles(wd, extensions, runConfig.exclude) +} + +func getFiles(wd string, extensions []string, exclude []string) []string { files := []string{} walkrepo.WalkRepo(wd, func(path string, d fs.FileInfo, err error) error { - // apply .tuido configured extensions if they exist, but do not - // read a configured writeto. writeto is decided by the root - // working directory or user config if d.IsDir() { + if isExcluded(path, wd, exclude) { + return filepath.SkipDir + } + // apply .tuido configured extensions/excludes if they exist, but do not + // read a configured writeto. writeto is decided by the root + // working directory or user config cfg := parseConfigIfExists(filepath.Join(path, ".tuido")) if cfg != nil { - extensions = cfg.extensions + if len(cfg.extensions) > 0 { + extensions = cfg.extensions + } + if len(cfg.exclude) > 0 { + exclude = append(exclude, cfg.exclude...) + } } + return nil } - for _, suffix := range extensions { + if isExcluded(path, wd, exclude) { + return nil + } - if strings.HasSuffix( - strings.ToLower(path), - suffix, - ) { + for _, suffix := range extensions { + if strings.HasSuffix(strings.ToLower(path), suffix) { files = append(files, path) } - } return nil }) return files } +// isExcluded reports whether path matches any exclude glob against its components +// relative to root. Each pattern is matched against individual path components +// (e.g. "node_modules") or as a full relative path glob (e.g. "src/*.gen.go"). +func isExcluded(path, root string, exclude []string) bool { + rel, err := filepath.Rel(root, path) + if err != nil { + return false + } + parts := strings.Split(rel, string(filepath.Separator)) + for _, pattern := range exclude { + pattern = strings.TrimSpace(pattern) + if pattern == "" { + continue + } + // match against each individual component + for _, part := range parts { + if matched, _ := filepath.Match(pattern, part); matched { + return true + } + } + // match against full relative path + if matched, _ := filepath.Match(pattern, rel); matched { + return true + } + } + return false +} + func SortItems(items []*tuido.Item) { sort.SliceStable(items, func(i, j int) bool { if items[i].Importance() > items[j].Importance() {