Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Always active regardless of focused panel.
| `/` | Fuzzy finder |
| `:` | Command palette |
| `~` | Change directory |
| `T` | Find TODO/FIXME markers |
| `ctrl+space` | Toggle AI chat |
| `ctrl+z` | Undo last git action |
| `ctrl+y` | Redo |
Expand Down
1 change: 1 addition & 0 deletions internal/keybindings/keybindings.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
{ "key": "/", "action": "Fuzzy finder" },
{ "key": ":", "action": "Command palette" },
{ "key": "~", "action": "Change directory" },
{ "key": "T", "action": "Find TODO/FIXME markers" },
{ "key": "ctrl+space", "action": "Toggle AI chat" },
{ "key": "ctrl+z", "action": "Undo last git action" },
{ "key": "ctrl+y", "action": "Redo" },
Expand Down
6 changes: 6 additions & 0 deletions internal/keymap/schemes/classic.toml
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,12 @@ action = "change_directory"
mode = "panel"
description = "Change directory"

[[bindings]]
key = "T"
action = "todo_finder"
mode = "panel"
description = "Find TODO/FIXME markers"

[[bindings]]
key = "/"
action = "search"
Expand Down
6 changes: 6 additions & 0 deletions internal/keymap/schemes/default.toml
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,12 @@ action = "change_directory"
mode = "global"
description = "Change directory"

[[bindings]]
key = "T"
action = "todo_finder"
mode = "global"
description = "Find TODO/FIXME markers"

# File tree specific
[[bindings]]
key = "h"
Expand Down
6 changes: 6 additions & 0 deletions internal/keymap/schemes/vim.toml
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,12 @@ action = "change_directory"
mode = "panel"
description = "Change directory"

[[bindings]]
key = "T"
action = "todo_finder"
mode = "panel"
description = "Find TODO/FIXME markers"

# File tree specific
[[bindings]]
key = "h"
Expand Down
5 changes: 4 additions & 1 deletion internal/overlayreg/overlayreg.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ func (f *Factory) NewSettingsPanel(
}

// NewFuzzyFinder creates a fuzzy finder overlay for the given mode.
// Supported modes: "files", "commands", "directories".
// Supported modes: "files", "commands", "directories", "todos".
// The bindings parameter is used for the "commands" mode source.
func (f *Factory) NewFuzzyFinder(mode string, bindings []keymap.Binding) panels.Panel {
cwd, err := os.Getwd()
Expand All @@ -73,6 +73,7 @@ func (f *Factory) NewFuzzyFinder(mode string, bindings []keymap.Binding) panels.
fuzzyfinder.NewCommandSource(bindings),
fuzzyfinder.NewBookmarkSource(f.bookmarkMgr),
fuzzyfinder.NewGitChangedSource(cwd),
fuzzyfinder.NewTodoSource(cwd),
}
defaultCategories := []string{fuzzyfinder.DefaultCategoryFile()}
switch mode {
Expand All @@ -82,6 +83,8 @@ func (f *Factory) NewFuzzyFinder(mode string, bindings []keymap.Binding) panels.
defaultCategories = []string{fuzzyfinder.DefaultCategoryCommand()}
case "directories":
defaultCategories = []string{fuzzyfinder.DefaultCategoryDirectory()}
case "todos":
defaultCategories = []string{fuzzyfinder.DefaultCategoryTodo()}
}
return fuzzyfinder.NewWithDefaultCategories(f.theme, defaultCategories, sources...)
}
5 changes: 5 additions & 0 deletions internal/panels/fuzzyfinder/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const (
categoryDirectory = "directory"
categoryFile = "file"
categoryGitChanged = "git"
categoryTodo = "todo"
actionCursorDown = "cursor_down"
dirGit = ".git"
)
Expand All @@ -17,6 +18,7 @@ const (
sourceNameCommands = "commands"
sourceNameBookmarks = "bookmarks"
sourceNameGitChanged = "git changed"
sourceNameTodos = "todos"
)

// DefaultCategoryFile returns the file category name for overlay factories.
Expand All @@ -27,3 +29,6 @@ func DefaultCategoryCommand() string { return categoryCommand }

// DefaultCategoryDirectory returns the directory category name for overlay factories.
func DefaultCategoryDirectory() string { return categoryDirectory }

// DefaultCategoryTodo returns the todo category name for overlay factories.
func DefaultCategoryTodo() string { return categoryTodo }
6 changes: 5 additions & 1 deletion internal/panels/fuzzyfinder/fuzzyfinder.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@ func categoriesForPrefix(prefix byte) (map[string]bool, string, bool) {
return categorySet(categoryBookmark), sourceNameBookmarks, true
case 'g', 'G':
return categorySet(categoryGitChanged), sourceNameGitChanged, true
case 't', 'T':
return categorySet(categoryTodo), sourceNameTodos, true
default:
return nil, "", false
}
Expand Down Expand Up @@ -182,6 +184,8 @@ func sourceLabelForCategories(categories map[string]bool) string {
return sourceNameBookmarks
case categories[categoryGitChanged]:
return sourceNameGitChanged
case categories[categoryTodo]:
return sourceNameTodos
default:
return ""
}
Expand Down Expand Up @@ -399,7 +403,7 @@ func (ff *FuzzyFinder) selectCurrent() tea.Cmd {
if !ok {
return nil
}
return panels.FileSelectedMsg{Path: path}
return panels.FileSelectedMsg{Path: path, Line: item.Line}
}
},
func() tea.Msg {
Expand Down
38 changes: 38 additions & 0 deletions internal/panels/fuzzyfinder/fuzzyfinder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,44 @@ func TestCtrlUClearsQuery(t *testing.T) {
// Command selection tests
// ---------------------------------------------------------------------------

func collectBatchMsgs(cmd tea.Cmd) []tea.Msg {
if cmd == nil {
return nil
}
msg := cmd()
if batch, ok := msg.(tea.BatchMsg); ok {
var out []tea.Msg
for _, c := range batch {
out = append(out, collectBatchMsgs(c)...)
}
return out
}
if msg != nil {
return []tea.Msg{msg}
}
return nil
}

func TestSelectCurrent_TodoItemCarriesLine(t *testing.T) {
items := []Item{
{Text: "main.go:42 TODO fix", Category: categoryTodo, Value: "/abs/main.go", Line: 42},
}
ff := newTestFinder(items)

_, cmd := ff.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
require.NotNil(t, cmd)

found := false
for _, m := range collectBatchMsgs(cmd) {
if fs, ok := m.(panels.FileSelectedMsg); ok {
assert.Equal(t, "/abs/main.go", fs.Path)
assert.Equal(t, 42, fs.Line)
found = true
}
}
assert.True(t, found, "expected a FileSelectedMsg carrying the todo line")
}

func TestCommandSourceSelectReturnsCommandItem(t *testing.T) {
bindings := []keymap.Binding{
{Key: "ctrl+c", Action: "quit", Description: "Quit grut", Mode: keymap.ModeGlobal},
Expand Down
1 change: 1 addition & 0 deletions internal/panels/fuzzyfinder/source.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ type Item struct {
Text string // Searchable text (what fuzzy matches against)
Description string // Secondary text shown in results
Category string // Source category for display grouping
Line int // 1-based line to jump to when selected (0 = none)
}

// ---------------------------------------------------------------------------
Expand Down
158 changes: 158 additions & 0 deletions internal/panels/fuzzyfinder/todosource.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package fuzzyfinder

import (
"bytes"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)

// Code-annotation markers recognized by the todo source.
const (
// maxTodoFileSize is the largest file the scanner reads. Files above this
// size are skipped to keep the scan fast and avoid pulling large blobs
// into memory.
maxTodoFileSize = 1 << 20 // 1 MiB
// maxTodoItems caps the number of results so a repository full of markers
// cannot produce an unbounded list.
maxTodoItems = 5000
// binarySniffLen is the number of leading bytes inspected for a NUL byte
// when deciding whether a file is binary.
binarySniffLen = 8192
)

// todoMarkerRe matches TODO, FIXME, HACK, BUG, and XXX when they appear as
// standalone uppercase tokens (not as part of a longer identifier). The
// surrounding boundaries are checked with lookarounds emulated by requiring a
// non-word character or string edge on each side.
var todoMarkerRe = regexp.MustCompile(`(^|[^0-9A-Za-z_])(TODO|FIXME|HACK|BUG|XXX)([^0-9A-Za-z_]|$)`)

// TodoSource scans tracked text files for code-annotation markers (TODO,
// FIXME, HACK, BUG, XXX) and lists each occurrence as a file:line entry. It
// reuses the same hidden-directory, non-navigable-directory, and .gitignore
// filtering as the file source so results stay scoped to source you actually
// track.
type TodoSource struct {
root string
}

// NewTodoSource creates a source that scans the given root directory for code
// annotations.
func NewTodoSource(root string) *TodoSource {
return &TodoSource{root: root}
}

// Name implements Source.
func (ts *TodoSource) Name() string { return sourceNameTodos }

// Items implements Source. It walks the tree rooted at ts.root, reads each
// tracked text file, and emits one item per annotation marker found.
func (ts *TodoSource) Items() []Item {
if ts == nil || ts.root == "" {
return nil
}
gi := loadGitIgnore(ts.root)
var items []Item
_ = filepath.WalkDir(ts.root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil //nolint:nilerr // non-matching entries silently skipped
}
if len(items) >= maxTodoItems {
return filepath.SkipAll
}
name := d.Name()
if name == dirGit && d.IsDir() {
return filepath.SkipDir
}
// Skip hidden directories and files.
if name != "." && strings.HasPrefix(name, ".") {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
if d.IsDir() {
if nonNavigableDirs[name] {
return filepath.SkipDir
}
return nil
}
rel, relErr := filepath.Rel(ts.root, path)
if relErr != nil {
rel = path
}
rel = filepath.ToSlash(rel)
if gi != nil && gi.MatchesPath(rel) {
return nil
}
items = append(items, scanFileTodos(path, rel)...)
if len(items) >= maxTodoItems {
items = items[:maxTodoItems]
return filepath.SkipAll
}
return nil
})
return items
}

// scanFileTodos reads a single file and returns an item for every annotation
// marker it contains. It skips files that are too large or that look binary.
func scanFileTodos(path, rel string) []Item {
info, err := os.Stat(path)
if err != nil || info.Size() > maxTodoFileSize {
return nil
}
data, err := os.ReadFile(path)
if err != nil || isBinary(data) {
return nil
}
var items []Item
lines := strings.Split(string(data), "\n")
for i, line := range lines {
marker, message, ok := matchTodoLine(line)
if !ok {
continue
}
lineNo := i + 1
items = append(items, Item{
Text: fmt.Sprintf("%s:%d %s %s", rel, lineNo, marker, message),
Description: marker,
Category: categoryTodo,
Value: path,
Line: lineNo,
})
}
return items
}

// matchTodoLine returns the marker and trailing annotation text for the first
// marker found on a line. ok is false when the line has no marker.
func matchTodoLine(line string) (marker, message string, ok bool) {
loc := todoMarkerRe.FindStringSubmatchIndex(line)
if loc == nil {
return "", "", false
}
// Submatch group 2 is the marker token (indices loc[4]:loc[5]).
marker = line[loc[4]:loc[5]]
// The annotation message is everything from the marker to the end of the
// line, with a leading colon and surrounding whitespace trimmed.
rest := strings.TrimSpace(line[loc[5]:])
rest = strings.TrimPrefix(rest, ":")
message = strings.TrimSpace(rest)
const maxMessageLen = 200
if len(message) > maxMessageLen {
message = message[:maxMessageLen]
}
return marker, message, true
}

// isBinary reports whether data looks like a binary file by checking the first
// chunk for a NUL byte, which almost never appears in text.
func isBinary(data []byte) bool {
if len(data) > binarySniffLen {
data = data[:binarySniffLen]
}
return bytes.IndexByte(data, 0) >= 0
}
Loading
Loading