Files
Pavel NaumovandPavel Naumov 9f43fcb352 fix: sync shell working directory for suggestions (#78)
## Summary
- synchronize the shell current working directory with Iris over the
existing IPC pipe
- use the synchronized directory for file, workspace, Git, and history
suggestions on macOS
- preserve the Linux `/proc/<pid>/cwd` and process-CWD fallbacks
- update zsh, bash, and fish integrations while preserving command exit
status

## Problem
On macOS, `/proc/<pid>/cwd` is unavailable, so `GetCWD` fell back to the
directory where Iris was launched, usually `$HOME`. After changing
directories in the shell, file suggestions could therefore continue to
come from `$HOME`.

## Tests
- `go test -race ./spec`
- `go test ./root -run ^'$'`
- `go test ./... -run ^'$'`
- generated zsh, bash, and fish hook syntax checks

Co-authored-by: Pavel Naumov <Pavel.Naumov@berekebank.kz>
2026-07-31 10:01:05 +07:00

207 lines
4.9 KiB
Go

package spec
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"github.com/versenilvis/iris/internal/config"
)
var (
// ShellPID tracks the underlying shell process.
ShellPID int
shellCWDMu sync.RWMutex
shellCWD string
)
// SetCWD updates the working directory reported by the underlying shell.
// Shell integrations call this whenever the prompt directory changes.
func SetCWD(cwd string) {
if cwd != "" && !filepath.IsAbs(cwd) {
return
}
shellCWDMu.Lock()
shellCWD = cwd
shellCWDMu.Unlock()
}
// GetCWD returns the current working directory of the underlying shell
// reported by shell integration. Linux can also read it from /proc/[pid]/cwd.
func GetCWD() string {
shellCWDMu.RLock()
cwd := shellCWD
shellCWDMu.RUnlock()
if cwd != "" {
return cwd
}
if ShellPID > 0 {
path := fmt.Sprintf("/proc/%d/cwd", ShellPID)
procCWD, err := os.Readlink(path)
if err == nil {
return procCWD
}
}
cwd, _ = os.Getwd()
return cwd
}
// FileGenerator provides directory and file suggestions.
// It handles nested paths (e.g., 'src/main') by resolving the directory
// and filtering based on the last path component
//
// E.g, we have both src/main.go and src/main.py
// you type "cat src/mai", partial = "src/mai"
// dir = "src/", filePrefix = "mai"
// search for which matches "mai" case, and show it (whici is both main.go and main.py)
// but if you type "go run src/mai" -> it will only shows suggestion about src/main.go
// NOTE: it will skip hidden file start with dot prefix unless configured otherwise
func FileGenerator(filters ...string) GeneratorFunc {
dirOnly := false
filterSet := make(map[string]bool)
for _, f := range filters {
if f == "/" {
dirOnly = true
continue
}
filterSet[strings.ToLower(f)] = true
}
return func(tokens []string, prefix string, partial string) []Suggestion {
base := GetCWD()
dir := base
filePrefix := partial
// check if the partial string contains path separators
if i := strings.LastIndexAny(partial, "/\\"); i != -1 {
pathDir := partial[:i+1]
if filepath.IsAbs(pathDir) || strings.HasPrefix(pathDir, "~") {
if strings.HasPrefix(pathDir, "~") {
home, _ := os.UserHomeDir()
pathDir = filepath.Join(home, pathDir[1:])
}
dir = pathDir
} else {
dir = filepath.Join(base, pathDir)
}
filePrefix = partial[i+1:]
}
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
// build the path prefix for the relative suggestions
// e.g. if partial is "src/m", then pathPrefix is "src/"
pathPrefix := ""
if i := strings.LastIndexAny(partial, "/\\"); i != -1 {
pathPrefix = partial[:i+1]
}
var results []Suggestion
for _, entry := range entries {
name := entry.Name()
// skip hidden files if not configured to show them
if !config.Get().UI.ShowHiddenFiles && strings.HasPrefix(entry.Name(), ".") {
continue
}
match := false
if filePrefix == "" {
match = true
} else if dirOnly {
match = strings.Contains(strings.ToLower(name), strings.ToLower(filePrefix))
} else {
match = HasPrefix(name, filePrefix)
}
if !match {
continue
}
fullPath := pathPrefix + name
isDir := entry.IsDir()
if !isDir && entry.Type()&os.ModeSymlink != 0 {
if info, err := os.Stat(filepath.Join(dir, name)); err == nil && info.IsDir() {
isDir = true
}
}
if isDir {
if dirOnly || len(filterSet) == 0 {
results = append(results, Suggestion{
Cmd: fullPath + "/",
Desc: "directory",
Priority: 50,
})
} else {
// scan only 1 level deeper if there is a filter
subEntries, err := os.ReadDir(filepath.Join(dir, name))
if err == nil {
for _, subEntry := range subEntries {
subIsDir := subEntry.IsDir()
if !subIsDir && subEntry.Type()&os.ModeSymlink != 0 {
if info, err := os.Stat(filepath.Join(dir, name, subEntry.Name())); err == nil && info.IsDir() {
subIsDir = true
}
}
if subIsDir {
continue
}
subName := subEntry.Name()
if strings.HasPrefix(subName, ".") {
continue
}
ext := strings.ToLower(filepath.Ext(subName))
if filterSet[ext] {
results = append(results, Suggestion{
Cmd: fullPath + "/" + subName,
Desc: "file",
Priority: 50,
})
}
}
}
}
continue
}
if dirOnly {
continue
}
// if filters are set, only show matching extensions
if len(filterSet) > 0 {
ext := strings.ToLower(filepath.Ext(name))
if !filterSet[ext] {
continue
}
}
desc := "file"
if ext := strings.ToLower(filepath.Ext(name)); ext != "" {
desc = strings.TrimPrefix(ext, ".")
}
results = append(results, Suggestion{
Cmd: fullPath,
Desc: desc,
Priority: 50,
})
}
sort.Slice(results, func(i, j int) bool {
return strings.ToLower(results[i].Cmd) < strings.ToLower(results[j].Cmd)
})
return results
}
}