`view` was the last user of `editor`, so leaving the setting behind would have made it exactly what `gitkey` was until recently: documented, parsed, and doing nothing. It is gone from the struct, the template, the environment, `config -k` and the settings table. Both names are free for aliases now, as `open` already was. The tests that used `editor` as their example of a project-overridable setting use `gitkey` instead, which is the same kind of thing and still exists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
217 lines
6.1 KiB
Go
217 lines
6.1 KiB
Go
package main
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"github.com/chzyer/readline"
|
|
)
|
|
|
|
// completer wires up Tab completion. A line headed for a shell — a '!' escape,
|
|
// or an alias that expands to one — is completed the way a shell would:
|
|
// executables for the command, paths for its arguments. Everything else goes to
|
|
// 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 cands, prefix, ok := completeShellLine(string(line[:pos])); ok {
|
|
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 completes 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("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
|
|
}
|