mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-26 21:36:31 +02:00
feat(editor): shell-style tab-completion for path tokens
Typing a path-like token and pressing Tab in the editor now completes
it against the filesystem, the way bash / zsh do. No popup, no UI -
the token is rewritten in place.
Recognised shapes:
~ or ~/foo - expanded via os.UserHomeDir(); the displayed
token keeps its ~ form after completion
/abs/path - absolute
./foo, ../foo - relative to cwd
foo/bar - any token containing a slash, relative to cwd
Bare words ('hello', 'fix') without a slash or tilde are still no-ops
on Tab so plain text isn't disturbed.
Behaviour matches a typical shell:
- one match: full replace, trailing / appended for directories so
the next Tab can dive in
- multiple matches: completes to the longest common prefix; if the
prefix is already what was typed, Tab is a no-op (no second-tab
'show options' list yet)
- dotfiles hidden unless the user typed a leading dot
Tab inside the slash-command and @-file popups still does what it did
before; the new path completion only kicks in when neither popup is
active.
This commit is contained in:
parent
4f008e8871
commit
8096aebd0c
1 changed files with 193 additions and 0 deletions
|
|
@ -1928,6 +1928,19 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
|
|||
}
|
||||
}
|
||||
|
||||
// Tab-complete a path token in the editor when no popup is open.
|
||||
// Recognises tokens that look like paths (start with ~, /, ./, ../
|
||||
// or contain a slash); shell-style completion expands ~, lists the
|
||||
// parent dir, and completes the basename to the longest common
|
||||
// prefix. Single match: full replace and trailing / for dirs.
|
||||
// No match: no-op. Plain bare words (no slash, no tilde) fall
|
||||
// through so Tab keeps its current no-op behaviour outside paths.
|
||||
if k.Kind == tui.KeyTab && !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
|
||||
if i.tryPathTabComplete() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if i.handleInputHistoryKey(k) {
|
||||
return false
|
||||
}
|
||||
|
|
@ -2399,6 +2412,186 @@ func buildStudyPrompt(arg, cwd string) string {
|
|||
return "Read and understand everything in the directory " + display + "."
|
||||
}
|
||||
|
||||
// tryPathTabComplete looks at the editor's current value, finds the
|
||||
// path-like token immediately before the cursor (in this codebase the
|
||||
// cursor is always at the end of the buffer after a keystroke, so
|
||||
// "before the cursor" is the trailing non-whitespace run), and rewrites
|
||||
// it to its shell-style completion against the filesystem.
|
||||
//
|
||||
// Returns true when it consumed the Tab keystroke (token recognised,
|
||||
// completion attempted — even if no candidates matched, the keystroke
|
||||
// is still consumed so it doesn't insert a literal tab character).
|
||||
// Returns false when the token doesn't look like a path; the caller
|
||||
// then lets Tab fall through to its normal no-op.
|
||||
//
|
||||
// Recognised path shapes:
|
||||
// - ~ or ~/foo expanded via os.UserHomeDir()
|
||||
// - /abs/path or /abs/path/foo absolute
|
||||
// - ./foo, ../foo, foo/bar relative to i.cfg.CWD
|
||||
//
|
||||
// A bare word like "hello" is not treated as a path so plain text
|
||||
// keeps Tab as a literal no-op.
|
||||
func (i *Interactive) tryPathTabComplete() bool {
|
||||
val := i.ed.Value()
|
||||
// Find the trailing run of non-whitespace.
|
||||
start := len(val)
|
||||
for start > 0 {
|
||||
r := val[start-1]
|
||||
if r == ' ' || r == '\t' || r == '\n' {
|
||||
break
|
||||
}
|
||||
start--
|
||||
}
|
||||
token := val[start:]
|
||||
if token == "" {
|
||||
return false
|
||||
}
|
||||
if !looksLikePathToken(token) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Resolve the absolute parent directory + base prefix to match.
|
||||
parentAbs, basePrefix, displayParent, ok := resolvePathTabToken(token, i.cfg.CWD)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
entries, err := os.ReadDir(parentAbs)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
var names []string
|
||||
var isDir []bool
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if !strings.HasPrefix(name, basePrefix) {
|
||||
continue
|
||||
}
|
||||
// Hide dotfiles unless the user explicitly typed a leading dot,
|
||||
// mirroring bash's default behaviour.
|
||||
if strings.HasPrefix(name, ".") && !strings.HasPrefix(basePrefix, ".") {
|
||||
continue
|
||||
}
|
||||
names = append(names, name)
|
||||
isDir = append(isDir, e.IsDir())
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
var completed string
|
||||
var completedIsDir bool
|
||||
if len(names) == 1 {
|
||||
completed = names[0]
|
||||
completedIsDir = isDir[0]
|
||||
} else {
|
||||
completed = longestCommonPrefix(names)
|
||||
if completed == basePrefix {
|
||||
// Already at the deepest unambiguous prefix; nothing to add.
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Build the replacement token in the same display form the user
|
||||
// typed (preserve ~ vs absolute vs relative).
|
||||
newToken := displayParent + completed
|
||||
if len(names) == 1 && completedIsDir {
|
||||
newToken += "/"
|
||||
}
|
||||
|
||||
i.ed.SetValue(val[:start] + newToken)
|
||||
i.invalidate()
|
||||
return true
|
||||
}
|
||||
|
||||
// looksLikePathToken reports whether tok is shaped like a filesystem
|
||||
// path. Paths must either start with ~, /, ./, ../ or contain a /.
|
||||
// Plain words are excluded so Tab on "hello" stays a no-op.
|
||||
func looksLikePathToken(tok string) bool {
|
||||
if tok == "" {
|
||||
return false
|
||||
}
|
||||
if tok[0] == '~' || tok[0] == '/' {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(tok, "./") || strings.HasPrefix(tok, "../") {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(tok, "/")
|
||||
}
|
||||
|
||||
// resolvePathTabToken splits tok into (absolute parent dir, basename
|
||||
// prefix to match, display-form parent the user typed). ok is false
|
||||
// when the parent dir can't be resolved (e.g. ~ with no $HOME).
|
||||
func resolvePathTabToken(tok, cwd string) (parentAbs, basePrefix, displayParent string, ok bool) {
|
||||
// Detect ~ expansion.
|
||||
expanded := tok
|
||||
homePrefix := ""
|
||||
if tok == "~" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
return "", "", "", false
|
||||
}
|
||||
// "~" alone: complete in $HOME. parent = home, base = "".
|
||||
return home, "", "~/", true
|
||||
}
|
||||
if strings.HasPrefix(tok, "~/") {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
return "", "", "", false
|
||||
}
|
||||
expanded = home + tok[1:]
|
||||
homePrefix = "~"
|
||||
}
|
||||
|
||||
dir, base := splitDirBase(expanded)
|
||||
if !filepath.IsAbs(dir) {
|
||||
dir = filepath.Join(cwd, dir)
|
||||
}
|
||||
|
||||
// Reconstruct the display form the user typed for the parent,
|
||||
// keeping ~ when they used it. The base is dropped — the caller
|
||||
// substitutes the completed name.
|
||||
display := tok[:len(tok)-len(base)]
|
||||
if homePrefix != "" && !strings.HasPrefix(display, "~") {
|
||||
display = homePrefix + display[len(homePrefix):]
|
||||
}
|
||||
return dir, base, display, true
|
||||
}
|
||||
|
||||
// splitDirBase is like filepath.Split but preserves the trailing
|
||||
// slash convention: "foo" => (".", "foo"); "foo/" => ("foo", "");
|
||||
// "a/b" => ("a/", "b"); "/" => ("/", ""). Returned dir always has
|
||||
// the trailing separator when non-empty so callers can rebuild paths
|
||||
// by concatenation.
|
||||
func splitDirBase(p string) (dir, base string) {
|
||||
if p == "" {
|
||||
return ".", ""
|
||||
}
|
||||
i := strings.LastIndex(p, "/")
|
||||
if i < 0 {
|
||||
return ".", p
|
||||
}
|
||||
return p[:i+1], p[i+1:]
|
||||
}
|
||||
|
||||
func longestCommonPrefix(ss []string) string {
|
||||
if len(ss) == 0 {
|
||||
return ""
|
||||
}
|
||||
prefix := ss[0]
|
||||
for _, s := range ss[1:] {
|
||||
n := 0
|
||||
for n < len(prefix) && n < len(s) && prefix[n] == s[n] {
|
||||
n++
|
||||
}
|
||||
prefix = prefix[:n]
|
||||
if prefix == "" {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return prefix
|
||||
}
|
||||
|
||||
func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
|
||||
parts := strings.Fields(cmd)
|
||||
switch parts[0] {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue