feat(spec): better file path suggestions (#41)

Enhance file/folder suggestion
This commit is contained in:
VERSE
2026-07-26 20:43:05 +07:00
committed by GitHub
parent c4e9bcdd88
commit 27e0792560
24 changed files with 183 additions and 16 deletions
+1
View File
@@ -8,6 +8,7 @@ func init() {
spec.Register(&spec.Spec{ spec.Register(&spec.Spec{
Name: "bat", Name: "bat",
Description: "A cat(1) clone with syntax highlighting and Git integration", Description: "A cat(1) clone with syntax highlighting and Git integration",
Generator: spec.FileGenerator(),
Options: []spec.Option{ Options: []spec.Option{
{Name: "-A", Description: "Show non-printable characters"}, {Name: "-A", Description: "Show non-printable characters"},
{Name: "-p", Description: "Show plain style, no decorations"}, {Name: "-p", Description: "Show plain style, no decorations"},
+1
View File
@@ -8,6 +8,7 @@ func init() {
spec.Register(&spec.Spec{ spec.Register(&spec.Spec{
Name: "code", Name: "code",
Description: "Read from stdin (e.g.", Description: "Read from stdin (e.g.",
Generator: spec.FileGenerator(),
Options: []spec.Option{ Options: []spec.Option{
{Name: "-d", Description: "Compare two files with each other"}, {Name: "-d", Description: "Compare two files with each other"},
{Name: "-m", Description: "Add folder(s) to the last active window"}, {Name: "-m", Description: "Add folder(s) to the last active window"},
+1
View File
@@ -8,5 +8,6 @@ func init() {
spec.Register(&spec.Spec{ spec.Register(&spec.Spec{
Name: "cot", Name: "cot",
Description: "Command-line utility for CotEditor", Description: "Command-line utility for CotEditor",
Generator: spec.FileGenerator(),
}) })
} }
+1
View File
@@ -8,5 +8,6 @@ func init() {
spec.Register(&spec.Spec{ spec.Register(&spec.Spec{
Name: "emacs", Name: "emacs",
Description: "An extensible, customizable, free/libre text editor - and more", Description: "An extensible, customizable, free/libre text editor - and more",
Generator: spec.FileGenerator(),
}) })
} }
+2
View File
@@ -8,6 +8,8 @@ func init() {
spec.Register(&spec.Spec{ spec.Register(&spec.Spec{
Name: "glow", Name: "glow",
Description: "Render markdown on the CLI, with pizzazz!", Description: "Render markdown on the CLI, with pizzazz!",
MaxArgs: 1,
Generator: spec.FileGenerator(),
Subcommands: []spec.Subcommand{ Subcommands: []spec.Subcommand{
{Name: "config", Description: "Edit the glow config file"}, {Name: "config", Description: "Edit the glow config file"},
{Name: "help", Description: "Help about any command"}, {Name: "help", Description: "Help about any command"},
+1
View File
@@ -8,6 +8,7 @@ func init() {
spec.Register(&spec.Spec{ spec.Register(&spec.Spec{
Name: "idea", Name: "idea",
Description: "IntelliJ IDEA CLI", Description: "IntelliJ IDEA CLI",
Generator: spec.FileGenerator(),
Subcommands: []spec.Subcommand{ Subcommands: []spec.Subcommand{
{Name: "diff", Description: "Open the diff viewer to see the differences between two specified files"}, {Name: "diff", Description: "Open the diff viewer to see the differences between two specified files"},
{Name: "merge", Description: "Open the Merge dialog to merge the specified files"}, {Name: "merge", Description: "Open the Merge dialog to merge the specified files"},
+1
View File
@@ -8,6 +8,7 @@ func init() {
spec.Register(&spec.Spec{ spec.Register(&spec.Spec{
Name: "less", Name: "less",
Description: "view file contents (scrollable)", Description: "view file contents (scrollable)",
MaxArgs: 1,
Generator: spec.FileGenerator(), Generator: spec.FileGenerator(),
}) })
} }
+1
View File
@@ -8,6 +8,7 @@ func init() {
spec.Register(&spec.Spec{ spec.Register(&spec.Spec{
Name: "lvim", Name: "lvim",
Description: "Hyperextensible Vim-based text editor", Description: "Hyperextensible Vim-based text editor",
Generator: spec.FileGenerator(),
Options: []spec.Option{ Options: []spec.Option{
{Name: "--cmd", Description: "Execute <cmd> before any config"}, {Name: "--cmd", Description: "Execute <cmd> before any config"},
{Name: "-c", Description: "Execute <cmd> after config and first file"}, {Name: "-c", Description: "Execute <cmd> after config and first file"},
+1
View File
@@ -8,6 +8,7 @@ func init() {
spec.Register(&spec.Spec{ spec.Register(&spec.Spec{
Name: "micro", Name: "micro",
Description: "True/false", Description: "True/false",
Generator: spec.FileGenerator(),
Subcommands: []spec.Subcommand{ Subcommands: []spec.Subcommand{
{Name: "remove", Description: "Remove plugin(s)"}, {Name: "remove", Description: "Remove plugin(s)"},
{Name: "update", Description: "Update plugin(s) (if no argument is given, updates all plugins)"}, {Name: "update", Description: "Update plugin(s) (if no argument is given, updates all plugins)"},
+2
View File
@@ -8,6 +8,8 @@ func init() {
spec.Register(&spec.Spec{ spec.Register(&spec.Spec{
Name: "more", Name: "more",
Description: "Opposite of less", Description: "Opposite of less",
MaxArgs: 1,
Generator: spec.FileGenerator(),
Options: []spec.Option{ Options: []spec.Option{
{Name: "-d", Description: "Do not pause after any line containing a ^L (form feed)"}, {Name: "-d", Description: "Do not pause after any line containing a ^L (form feed)"},
{Name: "-f", Description: "Count logical lines, rather than screen lines"}, {Name: "-f", Description: "Count logical lines, rather than screen lines"},
+1
View File
@@ -8,5 +8,6 @@ func init() {
spec.Register(&spec.Spec{ spec.Register(&spec.Spec{
Name: "nano", Name: "nano",
Description: "Nano", Description: "Nano",
Generator: spec.FileGenerator(),
}) })
} }
+1
View File
@@ -8,6 +8,7 @@ func init() {
spec.Register(&spec.Spec{ spec.Register(&spec.Spec{
Name: "nvim", Name: "nvim",
Description: "Hyperextensible Vim-based text editor", Description: "Hyperextensible Vim-based text editor",
Generator: spec.FileGenerator(),
Options: []spec.Option{ Options: []spec.Option{
{Name: "--cmd", Description: "Execute <cmd> before any config"}, {Name: "--cmd", Description: "Execute <cmd> before any config"},
{Name: "-c", Description: "Execute <cmd> after config and first file"}, {Name: "-c", Description: "Execute <cmd> after config and first file"},
+3 -1
View File
@@ -7,7 +7,9 @@ import (
func init() { func init() {
spec.Register(&spec.Spec{ spec.Register(&spec.Spec{
Name: "rich", Name: "rich",
Description: "Defined by terminal, appearance may differ", Description: "Rich terminal text formatting",
MaxArgs: 1,
Generator: spec.FileGenerator(),
Options: []spec.Option{ Options: []spec.Option{
{Name: "-p", Description: "Print console markup. See https://rich.readthedocs.io/en/latest/markup.html"}, {Name: "-p", Description: "Print console markup. See https://rich.readthedocs.io/en/latest/markup.html"},
{Name: "-u", Description: "Display a horizontal rule"}, {Name: "-u", Description: "Display a horizontal rule"},
+1
View File
@@ -8,6 +8,7 @@ func init() {
spec.Register(&spec.Spec{ spec.Register(&spec.Spec{
Name: "subl", Name: "subl",
Description: "Sublime Text", Description: "Sublime Text",
Generator: spec.FileGenerator(),
Options: []spec.Option{ Options: []spec.Option{
{Name: "--project", Description: "Load the given project"}, {Name: "--project", Description: "Load the given project"},
{Name: "--command", Description: "Run the given command"}, {Name: "--command", Description: "Run the given command"},
+1
View File
@@ -8,6 +8,7 @@ func init() {
spec.Register(&spec.Spec{ spec.Register(&spec.Spec{
Name: "vi", Name: "vi",
Description: "Print help message for vi and exit", Description: "Print help message for vi and exit",
Generator: spec.FileGenerator(),
Options: []spec.Option{ Options: []spec.Option{
{Name: "-h", Description: "Print help message for vi and exit"}, {Name: "-h", Description: "Print help message for vi and exit"},
}, },
+1
View File
@@ -8,6 +8,7 @@ func init() {
spec.Register(&spec.Spec{ spec.Register(&spec.Spec{
Name: "vim", Name: "vim",
Description: "Vi IMproved, a programmer", Description: "Vi IMproved, a programmer",
Generator: spec.FileGenerator(),
Options: []spec.Option{ Options: []spec.Option{
{Name: "-v", Description: "Vi mode (like 'vi')"}, {Name: "-v", Description: "Vi mode (like 'vi')"},
{Name: "-e", Description: "Ex mode (like 'ex')"}, {Name: "-e", Description: "Ex mode (like 'ex')"},
+1
View File
@@ -8,6 +8,7 @@ func init() {
spec.Register(&spec.Spec{ spec.Register(&spec.Spec{
Name: "vimr", Name: "vimr",
Description: "VimR - Neovim GUI for macOS in Swift", Description: "VimR - Neovim GUI for macOS in Swift",
Generator: spec.FileGenerator(),
Options: []spec.Option{ Options: []spec.Option{
{Name: "-h", Description: "Show help"}, {Name: "-h", Description: "Show help"},
{Name: "--dry-run", Description: "Just print the 'open' command"}, {Name: "--dry-run", Description: "Just print the 'open' command"},
+1
View File
@@ -8,6 +8,7 @@ func init() {
spec.Register(&spec.Spec{ spec.Register(&spec.Spec{
Name: "xed", Name: "xed",
Description: "Xcode text editor invocation tool", Description: "Xcode text editor invocation tool",
Generator: spec.FileGenerator(),
Options: []spec.Option{ Options: []spec.Option{
{Name: "--launch", Description: "Launches Xcode, opening a new empty unsaved file"}, {Name: "--launch", Description: "Launches Xcode, opening a new empty unsaved file"},
{Name: "--create", Description: "Selects the given line in the last file opened"}, {Name: "--create", Description: "Selects the given line in the last file opened"},
+1
View File
@@ -8,6 +8,7 @@ func init() {
spec.Register(&spec.Spec{ spec.Register(&spec.Spec{
Name: "xxd", Name: "xxd",
Description: "Make a hexdump or do the reverse", Description: "Make a hexdump or do the reverse",
Generator: spec.FileGenerator(),
Options: []spec.Option{ Options: []spec.Option{
{Name: "-help", Description: "Show help for xxd"}, {Name: "-help", Description: "Show help for xxd"},
{Name: "-autoskip", Description: "Toggle autoskip: A single '*' replaces nul-lines. Default off"}, {Name: "-autoskip", Description: "Toggle autoskip: A single '*' replaces nul-lines. Default off"},
+1
View File
@@ -8,6 +8,7 @@ func init() {
spec.Register(&spec.Spec{ spec.Register(&spec.Spec{
Name: "zed", Name: "zed",
Description: "A lightning-fast, collaborative code editor written in Rust", Description: "A lightning-fast, collaborative code editor written in Rust",
Generator: spec.FileGenerator(),
Options: []spec.Option{ Options: []spec.Option{
{Name: "-h", Description: "Print help information"}, {Name: "-h", Description: "Print help information"},
{Name: "-v", Description: "Print Zed's version and the app path"}, {Name: "-v", Description: "Print Zed's version and the app path"},
+12 -2
View File
@@ -850,7 +850,12 @@ func runWrapper() {
currentMode := activeMode currentMode := activeMode
activeModeMu.RUnlock() activeModeMu.RUnlock()
if currentMode == "spec" { if currentMode == "spec" {
selected = strings.TrimSpace(selected) + " " s := strings.TrimSpace(selected)
if strings.HasSuffix(s, "/") || strings.HasSuffix(s, "\\") {
selected = s
} else {
selected = s + " "
}
} }
_, _ = ptmx.Write(append([]byte{0x15}, selected...)) _, _ = ptmx.Write(append([]byte{0x15}, selected...))
} }
@@ -911,7 +916,12 @@ func runWrapper() {
currentMode := activeMode currentMode := activeMode
activeModeMu.RUnlock() activeModeMu.RUnlock()
if currentMode == "spec" { if currentMode == "spec" {
selected = strings.TrimSpace(selected) + " " s := strings.TrimSpace(selected)
if strings.HasSuffix(s, "/") || strings.HasSuffix(s, "\\") {
selected = s
} else {
selected = s + " "
}
} }
bufferMu.Lock() bufferMu.Lock()
+8 -1
View File
@@ -106,6 +106,7 @@ func FileGenerator(filters ...string) GeneratorFunc {
results = append(results, Suggestion{ results = append(results, Suggestion{
Cmd: fullPath + "/", Cmd: fullPath + "/",
Desc: "directory", Desc: "directory",
Priority: 50,
}) })
} else { } else {
// scan only 1 level deeper if there is a filter // scan only 1 level deeper if there is a filter
@@ -124,6 +125,7 @@ func FileGenerator(filters ...string) GeneratorFunc {
results = append(results, Suggestion{ results = append(results, Suggestion{
Cmd: fullPath + "/" + subName, Cmd: fullPath + "/" + subName,
Desc: "file", Desc: "file",
Priority: 50,
}) })
} }
} }
@@ -143,9 +145,14 @@ func FileGenerator(filters ...string) GeneratorFunc {
continue continue
} }
} }
desc := "file"
if ext := strings.ToLower(filepath.Ext(name)); ext != "" {
desc = strings.TrimPrefix(ext, ".")
}
results = append(results, Suggestion{ results = append(results, Suggestion{
Cmd: fullPath, Cmd: fullPath,
Desc: "file", Desc: desc,
Priority: 50,
}) })
} }
+39 -7
View File
@@ -61,9 +61,13 @@ func Lookup(input string) []Suggestion {
scanExternalCommands() scanExternalCommands()
// if you have an alias in your shell config like: alias gca="git commit -a" // if you have an alias in your shell config like: alias nv="nvim"
// if the first word match it, IRIS will suggest "git commit -a" // expand it even when there's only one token (e.g. "nv ") so that the
if len(tokens) > 1 { // target spec's Generator (FileGenerator etc.) can still fire.
// only expand when there's a trailing space — i.e. the user has committed
// to the alias name and is now typing arguments (tokens last elem == "")
hasTrailingSpace := len(tokens) > 0 && tokens[len(tokens)-1] == ""
if hasTrailingSpace || len(tokens) > 1 {
if target, ok := aliases[tokens[0]]; ok { if target, ok := aliases[tokens[0]]; ok {
aliasTokens := Tokenize(target) aliasTokens := Tokenize(target)
if len(aliasTokens) > 0 && aliasTokens[len(aliasTokens)-1] == "" { if len(aliasTokens) > 0 && aliasTokens[len(aliasTokens)-1] == "" {
@@ -202,10 +206,28 @@ func Lookup(input string) []Suggestion {
linePrefix := linePrefixBuilder.String() linePrefix := linePrefixBuilder.String()
if currentGen != nil && allowMoreArgs { if currentGen != nil && allowMoreArgs {
genResults := currentGen(tokens, prefix, partial) genPartial := partial
genLinePrefix := linePrefix
if partial == "" && len(tokens) >= 3 {
prevToken := tokens[len(tokens)-2]
if strings.HasSuffix(prevToken, "/") || strings.HasSuffix(prevToken, "\\") {
genPartial = prevToken
lpBuilder := strings.Builder{}
for i := 0; i < len(tokens)-2; i++ {
if i > 0 {
lpBuilder.WriteByte(' ')
}
lpBuilder.WriteString(tokens[i])
}
genLinePrefix = lpBuilder.String()
}
}
genResults := currentGen(tokens, prefix, genPartial)
for _, g := range genResults { for _, g := range genResults {
if partial != "" && !HasPrefix(g.Cmd, partial) && !strings.Contains(g.Cmd, partial) { if genPartial != "" && !HasPrefix(g.Cmd, genPartial) && !strings.Contains(g.Cmd, genPartial) {
continue continue
} }
@@ -220,8 +242,10 @@ func Lookup(input string) []Suggestion {
finalCmd := "" finalCmd := ""
if len(tokens) > depth+1 && strings.HasPrefix(g.Cmd, tokens[depth]) { if len(tokens) > depth+1 && strings.HasPrefix(g.Cmd, tokens[depth]) {
finalCmd = prefix + " " + suggested finalCmd = prefix + " " + suggested
} else if genLinePrefix != "" {
finalCmd = genLinePrefix + " " + suggested
} else { } else {
finalCmd = strings.TrimSpace(linePrefix) + " " + suggested finalCmd = suggested
} }
newTokens := Tokenize(finalCmd) newTokens := Tokenize(finalCmd)
@@ -267,8 +291,16 @@ func Lookup(input string) []Suggestion {
for _, opt := range currentOpts { for _, opt := range currentOpts {
trimmedOpt := strings.TrimLeft(opt.Name, "-") trimmedOpt := strings.TrimLeft(opt.Name, "-")
if !usedOpts[opt.Name] && (partial == "" || HasPrefix(opt.Name, partial) || HasPrefix(trimmedOpt, partial)) { if !usedOpts[opt.Name] && (partial == "" || HasPrefix(opt.Name, partial) || HasPrefix(trimmedOpt, partial)) {
optPriority := opt.Priority
if optPriority == 0 {
if strings.HasPrefix(partial, "-") {
optPriority = 80
} else {
optPriority = 10
}
}
results = append(results, Suggestion{ results = append(results, Suggestion{
Cmd: linePrefix + " " + opt.Name, Desc: opt.Description, Icon: rootCmdName, Priority: opt.Priority, Cmd: linePrefix + " " + opt.Name, Desc: opt.Description, Icon: rootCmdName, Priority: optPriority,
}) })
} }
} }
+95
View File
@@ -1,6 +1,8 @@
package spec package spec
import ( import (
"os"
"path/filepath"
"strings" "strings"
"sync" "sync"
"testing" "testing"
@@ -136,3 +138,96 @@ func TestLookupConcurrent(t *testing.T) {
} }
wg.Wait() wg.Wait()
} }
func TestLookup_AliasFileGenerator(t *testing.T) {
ResetRegistry()
Register(&Spec{
Name: "nvim",
Generator: FileGenerator(),
})
// alias "nv" -> "nvim", single token with trailing space
ShellAliases = map[string]string{"nv": "nvim"}
results := Lookup("nv ")
if len(results) == 0 {
t.Errorf("expected file suggestions for alias 'nv ' -> 'nvim', got none")
}
for _, r := range results {
if !strings.HasPrefix(r.Cmd, "nvim ") {
t.Errorf("expected suggestion to start with 'nvim ', got %q", r.Cmd)
}
}
}
func TestLookup_NvimFileGenerator(t *testing.T) {
ResetRegistry()
Register(&Spec{
Name: "nvim",
Generator: FileGenerator(),
})
results := Lookup("nvim ")
if len(results) == 0 {
t.Errorf("expected file suggestions for 'nvim ', got none")
}
}
func TestLookup_OptionAndFilePriority(t *testing.T) {
ResetRegistry()
Register(&Spec{
Name: "nvim",
Generator: FileGenerator(),
Options: []Option{
{Name: "-c", Description: "Execute cmd"},
{Name: "--cmd", Description: "Execute cmd before config"},
},
})
// When query is 'nvim ', files should be prioritized over flags
resultsEmpty := Lookup("nvim ")
if len(resultsEmpty) == 0 {
t.Fatalf("expected results for 'nvim ', got 0")
}
// first result should be a file or dir (Priority 50), not option (Priority 10)
if strings.HasPrefix(resultsEmpty[0].Cmd, "nvim -") {
t.Errorf("expected file/dir as top result for 'nvim ', got %q", resultsEmpty[0].Cmd)
}
// When query is 'nvim -', flags should be prioritized (Priority 80)
resultsDash := Lookup("nvim -")
if len(resultsDash) == 0 {
t.Fatalf("expected results for 'nvim -', got 0")
}
if !strings.HasPrefix(resultsDash[0].Cmd, "nvim -") {
t.Errorf("expected option as top result for 'nvim -', got %q", resultsDash[0].Cmd)
}
}
func TestLookup_NestedDirectoryTrailingSpace(t *testing.T) {
ResetRegistry()
Register(&Spec{
Name: "cat",
Generator: FileGenerator(),
})
tmpDir := t.TempDir()
subDir := filepath.Join(tmpDir, "subdir")
_ = os.Mkdir(subDir, 0755)
testFile := filepath.Join(subDir, "hello.txt")
_ = os.WriteFile(testFile, []byte("hi"), 0644)
query := "cat " + subDir + "/ "
results := Lookup(query)
if len(results) == 0 {
t.Fatalf("expected results for %q, got 0", query)
}
found := false
for _, r := range results {
if strings.Contains(r.Cmd, "hello.txt") {
found = true
break
}
}
if !found {
t.Errorf("expected hello.txt in results, got %v", results)
}
}