-
Notifications
You must be signed in to change notification settings - Fork 177
Add completion menu for file paths #145
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
kujtimiihoxha
merged 14 commits into
opencode-ai:main
from
Adictya:feat-autocomplete-for-file-paths
May 15, 2025
+933
−287
Merged
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
56c0c7f
feat(context-dialog): init
Adictya 265afbf
chore(simple-list): refactor with generics
Adictya 0b8a8e6
fix(complete-module): fix fzf issues
Adictya b58ecf8
fix(complete-module): add fallbacks when rg or fzf is not available
Adictya c70e5b3
chore(complete-module): code improvements
Adictya 0e5e75c
Merge branch 'main' into feat-autocomplete-for-file-paths
Adictya 3fd9229
chore(complete-module): cleanup
Adictya 98139ba
fix(complete-module): dialog keys cleanup
Adictya 3421231
fix(simple-list): add fallback message
Adictya 36446a8
fix(commands-dialog): refactor to use simple-list
Adictya 925bd67
fix(simple-list): add j and k keys
Adictya 0affc06
fix(complete-module): cleanup and minor bug fixes
Adictya 168061a
fix(complete-module): self review
Adictya 34e0182
fix(complete-module): remove old file
Adictya File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,191 @@ | ||
package completions | ||
|
||
import ( | ||
"bytes" | ||
"fmt" | ||
"os/exec" | ||
"path/filepath" | ||
|
||
"github.com/lithammer/fuzzysearch/fuzzy" | ||
"github.com/opencode-ai/opencode/internal/fileutil" | ||
"github.com/opencode-ai/opencode/internal/logging" | ||
"github.com/opencode-ai/opencode/internal/tui/components/dialog" | ||
) | ||
|
||
type filesAndFoldersContextGroup struct { | ||
prefix string | ||
} | ||
|
||
func (cg *filesAndFoldersContextGroup) GetId() string { | ||
return cg.prefix | ||
} | ||
|
||
func (cg *filesAndFoldersContextGroup) GetEntry() dialog.CompletionItemI { | ||
return dialog.NewCompletionItem(dialog.CompletionItem{ | ||
Title: "Files & Folders", | ||
Value: "files", | ||
}) | ||
} | ||
|
||
func processNullTerminatedOutput(outputBytes []byte) []string { | ||
if len(outputBytes) > 0 && outputBytes[len(outputBytes)-1] == 0 { | ||
outputBytes = outputBytes[:len(outputBytes)-1] | ||
} | ||
|
||
if len(outputBytes) == 0 { | ||
return []string{} | ||
} | ||
|
||
split := bytes.Split(outputBytes, []byte{0}) | ||
matches := make([]string, 0, len(split)) | ||
|
||
for _, p := range split { | ||
if len(p) == 0 { | ||
continue | ||
} | ||
|
||
path := string(p) | ||
path = filepath.Join(".", path) | ||
|
||
if !fileutil.SkipHidden(path) { | ||
matches = append(matches, path) | ||
} | ||
} | ||
|
||
return matches | ||
} | ||
|
||
func (cg *filesAndFoldersContextGroup) getFiles(query string) ([]string, error) { | ||
cmdRg := fileutil.GetRgCmd("") // No glob pattern for this use case | ||
cmdFzf := fileutil.GetFzfCmd(query) | ||
|
||
var matches []string | ||
// Case 1: Both rg and fzf available | ||
if cmdRg != nil && cmdFzf != nil { | ||
rgPipe, err := cmdRg.StdoutPipe() | ||
if err != nil { | ||
return nil, fmt.Errorf("failed to get rg stdout pipe: %w", err) | ||
} | ||
defer rgPipe.Close() | ||
|
||
cmdFzf.Stdin = rgPipe | ||
var fzfOut bytes.Buffer | ||
var fzfErr bytes.Buffer | ||
cmdFzf.Stdout = &fzfOut | ||
cmdFzf.Stderr = &fzfErr | ||
|
||
if err := cmdFzf.Start(); err != nil { | ||
return nil, fmt.Errorf("failed to start fzf: %w", err) | ||
} | ||
|
||
errRg := cmdRg.Run() | ||
errFzf := cmdFzf.Wait() | ||
|
||
if errRg != nil { | ||
logging.Warn(fmt.Sprintf("rg command failed during pipe: %v", errRg)) | ||
} | ||
|
||
if errFzf != nil { | ||
if exitErr, ok := errFzf.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { | ||
return []string{}, nil // No matches from fzf | ||
} | ||
return nil, fmt.Errorf("fzf command failed: %w\nStderr: %s", errFzf, fzfErr.String()) | ||
} | ||
|
||
matches = processNullTerminatedOutput(fzfOut.Bytes()) | ||
|
||
// Case 2: Only rg available | ||
} else if cmdRg != nil { | ||
logging.Debug("Using Ripgrep with fuzzy match fallback for file completions") | ||
var rgOut bytes.Buffer | ||
var rgErr bytes.Buffer | ||
cmdRg.Stdout = &rgOut | ||
cmdRg.Stderr = &rgErr | ||
|
||
if err := cmdRg.Run(); err != nil { | ||
return nil, fmt.Errorf("rg command failed: %w\nStderr: %s", err, rgErr.String()) | ||
} | ||
|
||
allFiles := processNullTerminatedOutput(rgOut.Bytes()) | ||
matches = fuzzy.Find(query, allFiles) | ||
|
||
// Case 3: Only fzf available | ||
} else if cmdFzf != nil { | ||
logging.Debug("Using FZF with doublestar fallback for file completions") | ||
files, _, err := fileutil.GlobWithDoublestar("**/*", ".", 0) | ||
if err != nil { | ||
return nil, fmt.Errorf("failed to list files for fzf: %w", err) | ||
} | ||
|
||
allFiles := make([]string, 0, len(files)) | ||
for _, file := range files { | ||
if !fileutil.SkipHidden(file) { | ||
allFiles = append(allFiles, file) | ||
} | ||
} | ||
|
||
var fzfIn bytes.Buffer | ||
for _, file := range allFiles { | ||
fzfIn.WriteString(file) | ||
fzfIn.WriteByte(0) | ||
} | ||
|
||
cmdFzf.Stdin = &fzfIn | ||
var fzfOut bytes.Buffer | ||
var fzfErr bytes.Buffer | ||
cmdFzf.Stdout = &fzfOut | ||
cmdFzf.Stderr = &fzfErr | ||
|
||
if err := cmdFzf.Run(); err != nil { | ||
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { | ||
return []string{}, nil | ||
} | ||
return nil, fmt.Errorf("fzf command failed: %w\nStderr: %s", err, fzfErr.String()) | ||
} | ||
|
||
matches = processNullTerminatedOutput(fzfOut.Bytes()) | ||
|
||
// Case 4: Fallback to doublestar with fuzzy match | ||
} else { | ||
logging.Debug("Using doublestar with fuzzy match for file completions") | ||
allFiles, _, err := fileutil.GlobWithDoublestar("**/*", ".", 0) | ||
if err != nil { | ||
return nil, fmt.Errorf("failed to glob files: %w", err) | ||
} | ||
|
||
filteredFiles := make([]string, 0, len(allFiles)) | ||
for _, file := range allFiles { | ||
if !fileutil.SkipHidden(file) { | ||
filteredFiles = append(filteredFiles, file) | ||
} | ||
} | ||
|
||
matches = fuzzy.Find(query, filteredFiles) | ||
} | ||
|
||
return matches, nil | ||
} | ||
|
||
func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.CompletionItemI, error) { | ||
matches, err := cg.getFiles(query) | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
items := make([]dialog.CompletionItemI, 0, len(matches)) | ||
for _, file := range matches { | ||
item := dialog.NewCompletionItem(dialog.CompletionItem{ | ||
Title: file, | ||
Value: file, | ||
}) | ||
items = append(items, item) | ||
} | ||
|
||
return items, nil | ||
} | ||
|
||
func NewFileAndFolderContextGroup() dialog.CompletionProvider { | ||
return &filesAndFoldersContextGroup{ | ||
prefix: "file", | ||
} | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,163 @@ | ||
package fileutil | ||
|
||
import ( | ||
"fmt" | ||
"io/fs" | ||
"os" | ||
"os/exec" | ||
"path/filepath" | ||
"sort" | ||
"strings" | ||
"time" | ||
|
||
"github.com/bmatcuk/doublestar/v4" | ||
"github.com/opencode-ai/opencode/internal/logging" | ||
) | ||
|
||
var ( | ||
rgPath string | ||
fzfPath string | ||
) | ||
|
||
func init() { | ||
var err error | ||
rgPath, err = exec.LookPath("rg") | ||
if err != nil { | ||
logging.Warn("Ripgrep (rg) not found in $PATH. Some features might be limited or slower.") | ||
rgPath = "" | ||
} | ||
fzfPath, err = exec.LookPath("fzf") | ||
if err != nil { | ||
logging.Warn("FZF not found in $PATH. Some features might be limited or slower.") | ||
fzfPath = "" | ||
} | ||
} | ||
|
||
func GetRgCmd(globPattern string) *exec.Cmd { | ||
if rgPath == "" { | ||
return nil | ||
} | ||
rgArgs := []string{ | ||
"--files", | ||
"-L", | ||
"--null", | ||
} | ||
if globPattern != "" { | ||
if !filepath.IsAbs(globPattern) && !strings.HasPrefix(globPattern, "/") { | ||
globPattern = "/" + globPattern | ||
} | ||
rgArgs = append(rgArgs, "--glob", globPattern) | ||
} | ||
cmd := exec.Command(rgPath, rgArgs...) | ||
cmd.Dir = "." | ||
return cmd | ||
} | ||
|
||
func GetFzfCmd(query string) *exec.Cmd { | ||
if fzfPath == "" { | ||
return nil | ||
} | ||
fzfArgs := []string{ | ||
"--filter", | ||
query, | ||
"--read0", | ||
"--print0", | ||
} | ||
cmd := exec.Command(fzfPath, fzfArgs...) | ||
cmd.Dir = "." | ||
return cmd | ||
} | ||
|
||
type FileInfo struct { | ||
Path string | ||
ModTime time.Time | ||
} | ||
|
||
func SkipHidden(path string) bool { | ||
// Check for hidden files (starting with a dot) | ||
base := filepath.Base(path) | ||
if base != "." && strings.HasPrefix(base, ".") { | ||
return true | ||
} | ||
|
||
commonIgnoredDirs := map[string]bool{ | ||
".opencode": true, | ||
"node_modules": true, | ||
"vendor": true, | ||
"dist": true, | ||
"build": true, | ||
"target": true, | ||
".git": true, | ||
".idea": true, | ||
".vscode": true, | ||
"__pycache__": true, | ||
"bin": true, | ||
"obj": true, | ||
"out": true, | ||
"coverage": true, | ||
"tmp": true, | ||
"temp": true, | ||
"logs": true, | ||
"generated": true, | ||
"bower_components": true, | ||
"jspm_packages": true, | ||
} | ||
|
||
parts := strings.Split(path, string(os.PathSeparator)) | ||
for _, part := range parts { | ||
if commonIgnoredDirs[part] { | ||
return true | ||
} | ||
} | ||
return false | ||
} | ||
|
||
func GlobWithDoublestar(pattern, searchPath string, limit int) ([]string, bool, error) { | ||
fsys := os.DirFS(searchPath) | ||
relPattern := strings.TrimPrefix(pattern, "/") | ||
var matches []FileInfo | ||
|
||
err := doublestar.GlobWalk(fsys, relPattern, func(path string, d fs.DirEntry) error { | ||
if d.IsDir() { | ||
return nil | ||
} | ||
if SkipHidden(path) { | ||
return nil | ||
} | ||
info, err := d.Info() | ||
if err != nil { | ||
return nil | ||
} | ||
absPath := path | ||
if !strings.HasPrefix(absPath, searchPath) && searchPath != "." { | ||
absPath = filepath.Join(searchPath, absPath) | ||
} else if !strings.HasPrefix(absPath, "/") && searchPath == "." { | ||
absPath = filepath.Join(searchPath, absPath) // Ensure relative paths are joined correctly | ||
} | ||
|
||
matches = append(matches, FileInfo{Path: absPath, ModTime: info.ModTime()}) | ||
if limit > 0 && len(matches) >= limit*2 { | ||
return fs.SkipAll | ||
} | ||
return nil | ||
}) | ||
if err != nil { | ||
return nil, false, fmt.Errorf("glob walk error: %w", err) | ||
} | ||
|
||
sort.Slice(matches, func(i, j int) bool { | ||
return matches[i].ModTime.After(matches[j].ModTime) | ||
}) | ||
|
||
truncated := false | ||
if limit > 0 && len(matches) > limit { | ||
matches = matches[:limit] | ||
truncated = true | ||
} | ||
|
||
results := make([]string, len(matches)) | ||
for i, m := range matches { | ||
results[i] = m.Path | ||
} | ||
return results, truncated, nil | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added opencode to common ignored dirs