files-folders.go

  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}