Skip to content

Commit 49c710b

Browse files
authored
Add completion menu for file paths (#145)
* feat(context-dialog): init * chore(simple-list): refactor with generics * fix(complete-module): fix fzf issues * fix(complete-module): add fallbacks when rg or fzf is not available * chore(complete-module): code improvements * chore(complete-module): cleanup * fix(complete-module): dialog keys cleanup * fix(simple-list): add fallback message * fix(commands-dialog): refactor to use simple-list * fix(simple-list): add j and k keys * fix(complete-module): cleanup and minor bug fixes * fix(complete-module): self review * fix(complete-module): remove old file
1 parent 3e42475 commit 49c710b

File tree

11 files changed

+933
-287
lines changed

11 files changed

+933
-287
lines changed

go.mod

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,6 @@ require (
3535

3636
require (
3737
cloud.google.com/go v0.116.0 // indirect
38-
github.com/google/go-cmp v0.7.0 // indirect
39-
github.com/gorilla/websocket v1.5.3 // indirect
4038
cloud.google.com/go/auth v0.13.0 // indirect
4139
cloud.google.com/go/compute/metadata v0.6.0 // indirect
4240
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
@@ -72,12 +70,15 @@ require (
7270
github.com/go-logr/stdr v1.2.2 // indirect
7371
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
7472
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
73+
github.com/google/go-cmp v0.7.0 // indirect
7574
github.com/google/s2a-go v0.1.8 // indirect
7675
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
7776
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
7877
github.com/gorilla/css v1.0.1 // indirect
78+
github.com/gorilla/websocket v1.5.3 // indirect
7979
github.com/inconshreveable/mousetrap v1.1.0 // indirect
8080
github.com/kylelemons/godebug v1.1.0 // indirect
81+
github.com/lithammer/fuzzysearch v1.1.8
8182
github.com/lucasb-eyer/go-colorful v1.2.0
8283
github.com/mattn/go-isatty v0.0.20 // indirect
8384
github.com/mattn/go-localereader v0.0.1 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
144144
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
145145
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
146146
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
147+
github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
148+
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
147149
github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231 h1:9rjt7AfnrXKNSZhp36A3/4QAZAwGGCGD/p8Bse26zms=
148150
github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231/go.mod h1:S5etECMx+sZnW0Gm100Ma9J1PgVCTgNyFaqGu2b08b4=
149151
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=

internal/completions/files-folders.go

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
package completions
2+
3+
import (
4+
"bytes"
5+
"fmt"
6+
"os/exec"
7+
"path/filepath"
8+
9+
"github.com/lithammer/fuzzysearch/fuzzy"
10+
"github.com/opencode-ai/opencode/internal/fileutil"
11+
"github.com/opencode-ai/opencode/internal/logging"
12+
"github.com/opencode-ai/opencode/internal/tui/components/dialog"
13+
)
14+
15+
type filesAndFoldersContextGroup struct {
16+
prefix string
17+
}
18+
19+
func (cg *filesAndFoldersContextGroup) GetId() string {
20+
return cg.prefix
21+
}
22+
23+
func (cg *filesAndFoldersContextGroup) GetEntry() dialog.CompletionItemI {
24+
return dialog.NewCompletionItem(dialog.CompletionItem{
25+
Title: "Files & Folders",
26+
Value: "files",
27+
})
28+
}
29+
30+
func processNullTerminatedOutput(outputBytes []byte) []string {
31+
if len(outputBytes) > 0 && outputBytes[len(outputBytes)-1] == 0 {
32+
outputBytes = outputBytes[:len(outputBytes)-1]
33+
}
34+
35+
if len(outputBytes) == 0 {
36+
return []string{}
37+
}
38+
39+
split := bytes.Split(outputBytes, []byte{0})
40+
matches := make([]string, 0, len(split))
41+
42+
for _, p := range split {
43+
if len(p) == 0 {
44+
continue
45+
}
46+
47+
path := string(p)
48+
path = filepath.Join(".", path)
49+
50+
if !fileutil.SkipHidden(path) {
51+
matches = append(matches, path)
52+
}
53+
}
54+
55+
return matches
56+
}
57+
58+
func (cg *filesAndFoldersContextGroup) getFiles(query string) ([]string, error) {
59+
cmdRg := fileutil.GetRgCmd("") // No glob pattern for this use case
60+
cmdFzf := fileutil.GetFzfCmd(query)
61+
62+
var matches []string
63+
// Case 1: Both rg and fzf available
64+
if cmdRg != nil && cmdFzf != nil {
65+
rgPipe, err := cmdRg.StdoutPipe()
66+
if err != nil {
67+
return nil, fmt.Errorf("failed to get rg stdout pipe: %w", err)
68+
}
69+
defer rgPipe.Close()
70+
71+
cmdFzf.Stdin = rgPipe
72+
var fzfOut bytes.Buffer
73+
var fzfErr bytes.Buffer
74+
cmdFzf.Stdout = &fzfOut
75+
cmdFzf.Stderr = &fzfErr
76+
77+
if err := cmdFzf.Start(); err != nil {
78+
return nil, fmt.Errorf("failed to start fzf: %w", err)
79+
}
80+
81+
errRg := cmdRg.Run()
82+
errFzf := cmdFzf.Wait()
83+
84+
if errRg != nil {
85+
logging.Warn(fmt.Sprintf("rg command failed during pipe: %v", errRg))
86+
}
87+
88+
if errFzf != nil {
89+
if exitErr, ok := errFzf.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
90+
return []string{}, nil // No matches from fzf
91+
}
92+
return nil, fmt.Errorf("fzf command failed: %w\nStderr: %s", errFzf, fzfErr.String())
93+
}
94+
95+
matches = processNullTerminatedOutput(fzfOut.Bytes())
96+
97+
// Case 2: Only rg available
98+
} else if cmdRg != nil {
99+
logging.Debug("Using Ripgrep with fuzzy match fallback for file completions")
100+
var rgOut bytes.Buffer
101+
var rgErr bytes.Buffer
102+
cmdRg.Stdout = &rgOut
103+
cmdRg.Stderr = &rgErr
104+
105+
if err := cmdRg.Run(); err != nil {
106+
return nil, fmt.Errorf("rg command failed: %w\nStderr: %s", err, rgErr.String())
107+
}
108+
109+
allFiles := processNullTerminatedOutput(rgOut.Bytes())
110+
matches = fuzzy.Find(query, allFiles)
111+
112+
// Case 3: Only fzf available
113+
} else if cmdFzf != nil {
114+
logging.Debug("Using FZF with doublestar fallback for file completions")
115+
files, _, err := fileutil.GlobWithDoublestar("**/*", ".", 0)
116+
if err != nil {
117+
return nil, fmt.Errorf("failed to list files for fzf: %w", err)
118+
}
119+
120+
allFiles := make([]string, 0, len(files))
121+
for _, file := range files {
122+
if !fileutil.SkipHidden(file) {
123+
allFiles = append(allFiles, file)
124+
}
125+
}
126+
127+
var fzfIn bytes.Buffer
128+
for _, file := range allFiles {
129+
fzfIn.WriteString(file)
130+
fzfIn.WriteByte(0)
131+
}
132+
133+
cmdFzf.Stdin = &fzfIn
134+
var fzfOut bytes.Buffer
135+
var fzfErr bytes.Buffer
136+
cmdFzf.Stdout = &fzfOut
137+
cmdFzf.Stderr = &fzfErr
138+
139+
if err := cmdFzf.Run(); err != nil {
140+
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
141+
return []string{}, nil
142+
}
143+
return nil, fmt.Errorf("fzf command failed: %w\nStderr: %s", err, fzfErr.String())
144+
}
145+
146+
matches = processNullTerminatedOutput(fzfOut.Bytes())
147+
148+
// Case 4: Fallback to doublestar with fuzzy match
149+
} else {
150+
logging.Debug("Using doublestar with fuzzy match for file completions")
151+
allFiles, _, err := fileutil.GlobWithDoublestar("**/*", ".", 0)
152+
if err != nil {
153+
return nil, fmt.Errorf("failed to glob files: %w", err)
154+
}
155+
156+
filteredFiles := make([]string, 0, len(allFiles))
157+
for _, file := range allFiles {
158+
if !fileutil.SkipHidden(file) {
159+
filteredFiles = append(filteredFiles, file)
160+
}
161+
}
162+
163+
matches = fuzzy.Find(query, filteredFiles)
164+
}
165+
166+
return matches, nil
167+
}
168+
169+
func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
170+
matches, err := cg.getFiles(query)
171+
if err != nil {
172+
return nil, err
173+
}
174+
175+
items := make([]dialog.CompletionItemI, 0, len(matches))
176+
for _, file := range matches {
177+
item := dialog.NewCompletionItem(dialog.CompletionItem{
178+
Title: file,
179+
Value: file,
180+
})
181+
items = append(items, item)
182+
}
183+
184+
return items, nil
185+
}
186+
187+
func NewFileAndFolderContextGroup() dialog.CompletionProvider {
188+
return &filesAndFoldersContextGroup{
189+
prefix: "file",
190+
}
191+
}

internal/fileutil/fileutil.go

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
package fileutil
2+
3+
import (
4+
"fmt"
5+
"io/fs"
6+
"os"
7+
"os/exec"
8+
"path/filepath"
9+
"sort"
10+
"strings"
11+
"time"
12+
13+
"github.com/bmatcuk/doublestar/v4"
14+
"github.com/opencode-ai/opencode/internal/logging"
15+
)
16+
17+
var (
18+
rgPath string
19+
fzfPath string
20+
)
21+
22+
func init() {
23+
var err error
24+
rgPath, err = exec.LookPath("rg")
25+
if err != nil {
26+
logging.Warn("Ripgrep (rg) not found in $PATH. Some features might be limited or slower.")
27+
rgPath = ""
28+
}
29+
fzfPath, err = exec.LookPath("fzf")
30+
if err != nil {
31+
logging.Warn("FZF not found in $PATH. Some features might be limited or slower.")
32+
fzfPath = ""
33+
}
34+
}
35+
36+
func GetRgCmd(globPattern string) *exec.Cmd {
37+
if rgPath == "" {
38+
return nil
39+
}
40+
rgArgs := []string{
41+
"--files",
42+
"-L",
43+
"--null",
44+
}
45+
if globPattern != "" {
46+
if !filepath.IsAbs(globPattern) && !strings.HasPrefix(globPattern, "/") {
47+
globPattern = "/" + globPattern
48+
}
49+
rgArgs = append(rgArgs, "--glob", globPattern)
50+
}
51+
cmd := exec.Command(rgPath, rgArgs...)
52+
cmd.Dir = "."
53+
return cmd
54+
}
55+
56+
func GetFzfCmd(query string) *exec.Cmd {
57+
if fzfPath == "" {
58+
return nil
59+
}
60+
fzfArgs := []string{
61+
"--filter",
62+
query,
63+
"--read0",
64+
"--print0",
65+
}
66+
cmd := exec.Command(fzfPath, fzfArgs...)
67+
cmd.Dir = "."
68+
return cmd
69+
}
70+
71+
type FileInfo struct {
72+
Path string
73+
ModTime time.Time
74+
}
75+
76+
func SkipHidden(path string) bool {
77+
// Check for hidden files (starting with a dot)
78+
base := filepath.Base(path)
79+
if base != "." && strings.HasPrefix(base, ".") {
80+
return true
81+
}
82+
83+
commonIgnoredDirs := map[string]bool{
84+
".opencode": true,
85+
"node_modules": true,
86+
"vendor": true,
87+
"dist": true,
88+
"build": true,
89+
"target": true,
90+
".git": true,
91+
".idea": true,
92+
".vscode": true,
93+
"__pycache__": true,
94+
"bin": true,
95+
"obj": true,
96+
"out": true,
97+
"coverage": true,
98+
"tmp": true,
99+
"temp": true,
100+
"logs": true,
101+
"generated": true,
102+
"bower_components": true,
103+
"jspm_packages": true,
104+
}
105+
106+
parts := strings.Split(path, string(os.PathSeparator))
107+
for _, part := range parts {
108+
if commonIgnoredDirs[part] {
109+
return true
110+
}
111+
}
112+
return false
113+
}
114+
115+
func GlobWithDoublestar(pattern, searchPath string, limit int) ([]string, bool, error) {
116+
fsys := os.DirFS(searchPath)
117+
relPattern := strings.TrimPrefix(pattern, "/")
118+
var matches []FileInfo
119+
120+
err := doublestar.GlobWalk(fsys, relPattern, func(path string, d fs.DirEntry) error {
121+
if d.IsDir() {
122+
return nil
123+
}
124+
if SkipHidden(path) {
125+
return nil
126+
}
127+
info, err := d.Info()
128+
if err != nil {
129+
return nil
130+
}
131+
absPath := path
132+
if !strings.HasPrefix(absPath, searchPath) && searchPath != "." {
133+
absPath = filepath.Join(searchPath, absPath)
134+
} else if !strings.HasPrefix(absPath, "/") && searchPath == "." {
135+
absPath = filepath.Join(searchPath, absPath) // Ensure relative paths are joined correctly
136+
}
137+
138+
matches = append(matches, FileInfo{Path: absPath, ModTime: info.ModTime()})
139+
if limit > 0 && len(matches) >= limit*2 {
140+
return fs.SkipAll
141+
}
142+
return nil
143+
})
144+
if err != nil {
145+
return nil, false, fmt.Errorf("glob walk error: %w", err)
146+
}
147+
148+
sort.Slice(matches, func(i, j int) bool {
149+
return matches[i].ModTime.After(matches[j].ModTime)
150+
})
151+
152+
truncated := false
153+
if limit > 0 && len(matches) > limit {
154+
matches = matches[:limit]
155+
truncated = true
156+
}
157+
158+
results := make([]string, len(matches))
159+
for i, m := range matches {
160+
results[i] = m.Path
161+
}
162+
return results, truncated, nil
163+
}

0 commit comments

Comments
 (0)