1package completions
2
3import (
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
15type filesAndFoldersContextGroup struct {
16 prefix string
17}
18
19func (cg *filesAndFoldersContextGroup) GetId() string {
20 return cg.prefix
21}
22
23func (cg *filesAndFoldersContextGroup) GetEntry() dialog.CompletionItemI {
24 return dialog.NewCompletionItem(dialog.CompletionItem{
25 Title: "Files & Folders",
26 Value: "files",
27 })
28}
29
30func 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
58func (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
169func (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
187func NewFileAndFolderContextGroup() dialog.CompletionProvider {
188 return &filesAndFoldersContextGroup{
189 prefix: "file",
190 }
191}