A line starting with '!' was not in the completer tree at all, so Tab did nothing there -- just where the paths are longest. It now completes the way a shell does: the command word against the executables on PATH, the arguments against the filesystem, resolved relative to the active project because that is where forwardShell runs the line. Directories complete with their trailing slash, `~/` and absolute paths work, and dot entries stay hidden until the prefix asks for one. readline's completer is a tree of fixed words, which cannot express "a prefix that is not a word", so this is a small AutoCompleter that dispatches on the '!' and hands everything else to the existing tree. Its contract is easy to get subtly wrong -- candidates are the suffixes still missing, and the length is counted in runes, not bytes -- so the conversion has its own test, as does completing only the basename inside a directory, which is what keeps the candidate list readable. Only the basename is offered inside a directory, PATH is scanned once per session, and a bare '!' offers nothing rather than every executable on the machine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
219 lines
6.3 KiB
Go
219 lines
6.3 KiB
Go
package main
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"github.com/chzyer/readline"
|
|
)
|
|
|
|
// completer wires up Tab completion. A line starting with '!' is completed the
|
|
// way a shell would — executables for the command, paths for its arguments —
|
|
// and everything else against the builtin command tree.
|
|
func completer() readline.AutoCompleter {
|
|
return &mgshCompleter{builtin: builtinCompleter()}
|
|
}
|
|
|
|
// mgshCompleter dispatches between the two completion worlds.
|
|
type mgshCompleter struct{ builtin *readline.PrefixCompleter }
|
|
|
|
func (c *mgshCompleter) Do(line []rune, pos int) ([][]rune, int) {
|
|
if pos > len(line) {
|
|
pos = len(line)
|
|
}
|
|
if strings.HasPrefix(strings.TrimLeft(string(line[:pos]), " \t"), "!") {
|
|
cands, prefix := shellCandidates(string(line[:pos]))
|
|
return runeSuffixes(cands, prefix)
|
|
}
|
|
return c.builtin.Do(line, pos)
|
|
}
|
|
|
|
// runeSuffixes converts full candidate words into what readline wants: the part
|
|
// still missing after the prefix already typed, plus that prefix's length.
|
|
func runeSuffixes(cands []string, prefix string) ([][]rune, int) {
|
|
n := utf8.RuneCountInString(prefix)
|
|
out := make([][]rune, 0, len(cands))
|
|
for _, c := range cands {
|
|
r := []rune(c)
|
|
if len(r) >= n {
|
|
out = append(out, r[n:])
|
|
}
|
|
}
|
|
return out, n
|
|
}
|
|
|
|
// builtinCompleter is the command tree. Command names complete at the start of
|
|
// the line; cd/open/view complete local project names; clone/show complete
|
|
// repository names cached from the git server; checkout/tag complete branch and
|
|
// tag names; pushremote/release complete mirror targets; dist completes
|
|
// filesystem paths.
|
|
func builtinCompleter() *readline.PrefixCompleter {
|
|
return readline.NewPrefixCompleter(
|
|
readline.PcItem("cd", readline.PcItemDynamic(dynLocalProjects)),
|
|
readline.PcItem("open", readline.PcItemDynamic(dynLocalProjects)),
|
|
readline.PcItem("view", readline.PcItemDynamic(dynLocalProjects)),
|
|
readline.PcItem("clone",
|
|
readline.PcItem("-a", readline.PcItemDynamic(dynServerArchives)),
|
|
readline.PcItemDynamic(dynServerRepos),
|
|
),
|
|
readline.PcItem("cloneall"),
|
|
readline.PcItem("show", readline.PcItemDynamic(dynServerRepos)),
|
|
readline.PcItem("list", readline.PcItem("-a")),
|
|
readline.PcItem("push"),
|
|
readline.PcItem("pushremote", readline.PcItemDynamic(dynRemoteNames)),
|
|
readline.PcItem("release", readline.PcItemDynamic(dynRemoteNames)),
|
|
readline.PcItem("pull"),
|
|
readline.PcItem("fetch"),
|
|
readline.PcItem("status", readline.PcItem("-a")),
|
|
readline.PcItem("overview"),
|
|
readline.PcItem("diff"),
|
|
readline.PcItem("init"),
|
|
readline.PcItem("edit"),
|
|
readline.PcItem("checkout", readline.PcItemDynamic(dynCheckout)),
|
|
readline.PcItem("log"),
|
|
readline.PcItem("archive"),
|
|
readline.PcItem("dist", readline.PcItemDynamic(dynPaths)),
|
|
readline.PcItem("login"),
|
|
readline.PcItem("count"),
|
|
readline.PcItem("tag",
|
|
readline.PcItem("add"),
|
|
readline.PcItem("checkout", readline.PcItemDynamic(dynTags)),
|
|
readline.PcItem("delete", readline.PcItemDynamic(dynTags)),
|
|
),
|
|
readline.PcItem("alias", readline.PcItemDynamic(dynAliasNames)),
|
|
readline.PcItem("unalias", readline.PcItemDynamic(dynAliasNames)),
|
|
readline.PcItem("config", readline.PcItem("-k")),
|
|
readline.PcItem("rescan"),
|
|
readline.PcItem("help"),
|
|
readline.PcItem("quit"),
|
|
readline.PcItem("exit"),
|
|
)
|
|
}
|
|
|
|
// dynLocalProjects lists project directories under BASE (already name-sorted).
|
|
func dynLocalProjects(string) []string {
|
|
entries, err := os.ReadDir(BASE)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var out []string
|
|
for _, e := range entries {
|
|
if e.IsDir() && !strings.HasPrefix(e.Name(), ".") {
|
|
out = append(out, e.Name())
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
var (
|
|
serverRepos []string
|
|
serverArchives []string
|
|
serverFetched bool
|
|
)
|
|
|
|
// fetchServerRepos queries the server once and caches the repository/archive
|
|
// names for Tab completion.
|
|
func fetchServerRepos() {
|
|
if serverFetched {
|
|
return
|
|
}
|
|
repos, err := serverRepoNames()
|
|
if err != nil {
|
|
// a transient failure (server down, no network) must not cache an
|
|
// empty list for the rest of the session — the next Tab tries again
|
|
return
|
|
}
|
|
// a missing ./archive is a permanent, unremarkable state: still cache
|
|
var archives []string
|
|
if lines, err := sshOut("/bin/ls archive"); err == nil {
|
|
for _, ln := range lines {
|
|
t := strings.TrimSpace(ln)
|
|
if strings.HasSuffix(t, ".git.tar.gz") {
|
|
archives = append(archives, strings.TrimSuffix(t, ".git.tar.gz"))
|
|
}
|
|
}
|
|
}
|
|
serverRepos, serverArchives = repos, archives
|
|
serverFetched = true
|
|
}
|
|
|
|
// rescanServer clears the cached server listing so the next completion (or use)
|
|
// re-fetches it.
|
|
func rescanServer() {
|
|
serverFetched = false
|
|
serverRepos = nil
|
|
serverArchives = nil
|
|
}
|
|
|
|
func dynServerRepos(string) []string { fetchServerRepos(); return serverRepos }
|
|
func dynServerArchives(string) []string { fetchServerRepos(); return serverArchives }
|
|
|
|
// dynRemoteNames offers the configured mirror targets as `@name` selectors for
|
|
// `pushremote`, resolved against the active project's configuration.
|
|
func dynRemoteNames(string) []string {
|
|
targets, _ := cfg.mirrorTargets()
|
|
var out []string
|
|
for _, t := range targets {
|
|
out = append(out, "@"+t.Name)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// dynBranches / dynTags list the active project's local branches / tags.
|
|
func dynBranches(string) []string {
|
|
if !isDir(DIR + "/.git") {
|
|
return nil
|
|
}
|
|
out, _ := gitCapture(DIR, "for-each-ref", "--format=%(refname:short)", "refs/heads")
|
|
return splitLines(out)
|
|
}
|
|
|
|
func dynTags(string) []string {
|
|
if !isDir(DIR + "/.git") {
|
|
return nil
|
|
}
|
|
out, _ := gitCapture(DIR, "tag", "-l")
|
|
return splitLines(out)
|
|
}
|
|
|
|
// dynCheckout offers both branches and tags for `checkout`.
|
|
func dynCheckout(line string) []string {
|
|
return append(dynBranches(line), dynTags(line)...)
|
|
}
|
|
|
|
// dynPaths completes filesystem paths for the last token on the line.
|
|
func dynPaths(line string) []string {
|
|
partial := ""
|
|
if !strings.HasSuffix(line, " ") {
|
|
if f := strings.Fields(line); len(f) > 0 {
|
|
partial = f[len(f)-1]
|
|
}
|
|
}
|
|
dir := "."
|
|
if partial != "" {
|
|
if strings.HasSuffix(partial, "/") {
|
|
dir = partial
|
|
} else {
|
|
dir = filepath.Dir(partial)
|
|
}
|
|
}
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var out []string
|
|
for _, e := range entries {
|
|
if strings.HasPrefix(e.Name(), ".") {
|
|
continue
|
|
}
|
|
p := filepath.Join(dir, e.Name())
|
|
if e.IsDir() {
|
|
p += "/"
|
|
}
|
|
out = append(out, p)
|
|
}
|
|
return out
|
|
}
|