diff --git a/README.md b/README.md index f83d4e2..54bdb0c 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Midday Commander (mdc) brings the classic dual-panel file management paradigm in - **Fuzzy finder** - recursive file search with real-time fuzzy matching - **Bookmarks** to quickly jump to most visited locations - **Configurable keybindings** - every key is remappable via `config.toml` +- **Smart autocomplete** - path and command suggestions with `Tab` completion in Go to path and Execute command - **File operations** - copy, move, delete, rename, mkdir with confirmation dialogs - **Live theme picker** - browse and preview themes with Ctrl-T - **Multi-file selection** - tag files with Insert or Shift+Arrow for batch operations @@ -99,7 +100,8 @@ The left panel opens in the current directory, the right panel in your home dire | `Esc Esc` | Quit (double-press) | | `Tab` | Switch active panel | | `Ctrl-U` | Swap panels | -| `Ctrl-G` | Go to path | +| `Ctrl-G` | Go to path (with directory autocomplete) | +| `Ctrl-R` | Execute command (with path/command autocomplete; inside overlay, `Ctrl+E` toggles exec-only mode) | | `Ctrl-P` | Fuzzy finder | | `Ctrl-B` | Bookmarks | | `Ctrl-T` | Theme picker (live preview) | diff --git a/config.example.toml b/config.example.toml index 9a58f0d..9b9fc47 100644 --- a/config.example.toml +++ b/config.example.toml @@ -44,18 +44,19 @@ end = "end" go_back = "backspace" # Selection -toggle_select = "ctrl+t" +toggle_select = "insert" select_up = "shift+up" select_down = "shift+down" # Search quick_search = "ctrl+s" -# Go to path +# Dialogs goto = "ctrl+g" fuzzy_find = ["f9", "ctrl+p"] bookmarks = ["f2", "ctrl+b"] -help = "f1" +help = ["f1"] theme_picker = "ctrl+t" cmd_exec = "ctrl+r" terminal = "ctrl+o" +toggle_hidden = "ctrl+h" diff --git a/internal/app/app.go b/internal/app/app.go index 56902d4..4962102 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -641,7 +641,7 @@ func (m Model) startRename() (tea.Model, tea.Cmd) { } func (m Model) startGoTo() (tea.Model, tea.Cmd) { - d := dialog.NewInput("Go To", "Path:", m.activePanel().Path(), tagGoTo) + d := dialog.NewInputWithBase("Go To", "Path:", m.activePanel().Path(), tagGoTo, m.activePanel().Path()) m.dialog = &d return m, nil } diff --git a/internal/ui/cmdexec/cmdexec.go b/internal/ui/cmdexec/cmdexec.go index 27d4862..723178d 100644 --- a/internal/ui/cmdexec/cmdexec.go +++ b/internal/ui/cmdexec/cmdexec.go @@ -4,11 +4,13 @@ import ( "bytes" "fmt" "os/exec" + "sort" "strings" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/kooler/MiddayCommander/internal/ui/completion" "github.com/kooler/MiddayCommander/internal/ui/overlay" "github.com/kooler/MiddayCommander/internal/ui/theme" ) @@ -31,6 +33,8 @@ type Model struct { outputOffset int running bool dir string + suggestions []string + execOnly bool width int height int } @@ -92,19 +96,41 @@ func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) { m.output = "" m.outputLines = nil m.outputOffset = 0 + m.suggestions = nil return m, runCommandCmd(m.dir, m.input) } + case "tab": + m.output = "" + m.outputLines = nil + m.outputOffset = 0 + return m.completeCurrentWord(), nil + + case "ctrl+e": + m.output = "" + m.outputLines = nil + m.outputOffset = 0 + m.execOnly = !m.execOnly + return m.updateSuggestions(), nil + case "backspace": if m.inputPos > 0 { m.input = m.input[:m.inputPos-1] + m.input[m.inputPos:] m.inputPos-- } + m.output = "" + m.outputLines = nil + m.outputOffset = 0 + m = m.updateSuggestions() case "delete": if m.inputPos < len(m.input) { m.input = m.input[:m.inputPos] + m.input[m.inputPos+1:] } + m.output = "" + m.outputLines = nil + m.outputOffset = 0 + m = m.updateSuggestions() case "left": if m.inputPos > 0 { @@ -155,8 +181,17 @@ func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) { default: s := msg.String() if len(s) == 1 && s[0] >= 32 { + if m.inputPos < 0 { + m.inputPos = 0 + } else if m.inputPos > len(m.input) { + m.inputPos = len(m.input) + } m.input = m.input[:m.inputPos] + s + m.input[m.inputPos:] m.inputPos++ + m.output = "" + m.outputLines = nil + m.outputOffset = 0 + m = m.updateSuggestions() } } @@ -216,7 +251,7 @@ func (m Model) View(th theme.Theme, screenWidth, screenHeight int) string { // Input line with cursor var inputDisplay string if m.inputPos < len(m.input) { - inputDisplay = m.input[:m.inputPos] + "█" + m.input[m.inputPos+1:] + inputDisplay = m.input[:m.inputPos] + "█" + m.input[m.inputPos:] } else { inputDisplay = m.input + "█" } @@ -261,6 +296,15 @@ func (m Model) View(th theme.Theme, screenWidth, screenHeight int) string { } contentLines = append(contentLines, rendered) } + } else if len(m.suggestions) > 0 { + oh := boxH - 6 + if oh < 1 { + oh = 1 + } + suggestionLines := formatSuggestions(m.suggestions, innerW, oh) + for _, line := range suggestionLines { + contentLines = append(contentLines, dimStyle.Render(line)) + } } else { hint := dimStyle.Render(" Type a command and press Enter") hintWidth := lipgloss.Width(hint) @@ -274,7 +318,13 @@ func (m Model) View(th theme.Theme, screenWidth, screenHeight int) string { footerKeyStyle := lipgloss.NewStyle().Background(bg).Foreground(accent).Bold(true) footer := footerKeyStyle.Render(" Enter") + dimStyle.Render(":Run ") + footerKeyStyle.Render("Esc") + dimStyle.Render(":Close ") + - footerKeyStyle.Render("↑↓") + dimStyle.Render(":Scroll") + footerKeyStyle.Render("↑↓") + dimStyle.Render(":Scroll ") + + footerKeyStyle.Render("Ctrl+E") + dimStyle.Render(":") + if m.execOnly { + footer += dimStyle.Render("ExecOnly") + } else { + footer += dimStyle.Render("All") + } if len(m.outputLines) > oh { scrollInfo := fmt.Sprintf(" [%d-%d/%d]", m.outputOffset+1, @@ -302,3 +352,85 @@ func runCommandCmd(dir, command string) tea.Cmd { return CommandDoneMsg{Output: buf.String(), Err: err} } } + +func (m Model) completeCurrentWord() Model { + start, end, prefix := completion.CurrentWord(m.input, m.inputPos) + if prefix == "" { + return m + } + + candidates := completeCandidates(prefix, m.dir, m.execOnly) + m.suggestions = candidates + if len(candidates) == 0 { + return m + } + + if len(candidates) == 1 { + m.input = m.input[:start] + mergeCompletion(candidates[0], m.input[end:]) + m.inputPos = start + len(candidates[0]) + m.suggestions = nil + return m + } + + common := completion.CommonPrefix(candidates) + if len(common) > len(prefix) { + m.input = m.input[:start] + mergeCompletion(common, m.input[end:]) + m.inputPos = start + len(common) + m.suggestions = nil + } + + return m +} + +func (m Model) updateSuggestions() Model { + _, _, prefix := completion.CurrentWord(m.input, m.inputPos) + if prefix == "" { + if m.execOnly { + m.suggestions = completeCandidates(prefix, m.dir, m.execOnly) + } else { + m.suggestions = nil + } + return m + } + m.suggestions = completeCandidates(prefix, m.dir, m.execOnly) + return m +} + +func mergeCompletion(candidate, suffix string) string { + for i := len(candidate); i > 0; i-- { + if strings.HasPrefix(suffix, candidate[len(candidate)-i:]) { + return candidate + suffix[i:] + } + } + return candidate + suffix +} + +func completeCandidates(prefix, dir string, execOnly bool) []string { + if execOnly { + return completion.CompleteExecCandidates(prefix) + } + + pathCandidates := completion.CompletePathCandidates(prefix, dir, false) + execCandidates := []string{} + if len(pathCandidates) == 0 && !strings.Contains(prefix, "/") { + execCandidates = completion.CompleteExecCandidates(prefix) + } + candidates := make(map[string]struct{}) + for _, c := range pathCandidates { + candidates[c] = struct{}{} + } + for _, c := range execCandidates { + candidates[c] = struct{}{} + } + + var out []string + for c := range candidates { + out = append(out, c) + } + sort.Strings(out) + return out +} + +func formatSuggestions(suggestions []string, width, maxLines int) []string { + return completion.FormatSuggestions(suggestions, width, maxLines, true) +} diff --git a/internal/ui/completion/completion.go b/internal/ui/completion/completion.go new file mode 100644 index 0000000..89aef52 --- /dev/null +++ b/internal/ui/completion/completion.go @@ -0,0 +1,281 @@ +package completion + +import ( + "os" + "path/filepath" + "sort" + "strings" + "sync" + + "github.com/charmbracelet/lipgloss" +) + +var ( + execCacheMu sync.Mutex + execPathEnv string + execCandidateCache []string +) + +func ExpandTilde(path string) string { + if !strings.HasPrefix(path, "~") { + return path + } + home, err := os.UserHomeDir() + if err != nil { + return path + } + if path == "~" { + return home + } + if strings.HasPrefix(path, "~/") { + return filepath.Join(home, path[2:]) + } + return path +} + +func CommonPrefix(strs []string) string { + if len(strs) == 0 { + return "" + } + prefix := strs[0] + for _, s := range strs[1:] { + for !strings.HasPrefix(s, prefix) { + if prefix == "" { + return "" + } + prefix = prefix[:len(prefix)-1] + } + } + return prefix +} + +func PadOrTrim(s string, width int) string { + if lipgloss.Width(s) > width { + if width > 3 { + return s[:width-3] + "..." + } + return s[:width] + } + return s + strings.Repeat(" ", width-lipgloss.Width(s)) +} + +func FormatSuggestions(suggestions []string, width, maxLines int, basename bool) []string { + if len(suggestions) == 0 { + return nil + } + + var lines []string + currentLine := " " + currentWidth := 1 + itemSpacing := 2 + + for i, sug := range suggestions { + displayName := sug + if basename { + displayName = filepath.Base(strings.TrimRight(sug, string(os.PathSeparator))) + } + sugWidth := lipgloss.Width(displayName) + neededWidth := sugWidth + itemSpacing + if i == 0 { + neededWidth = sugWidth + 1 + } + + if currentWidth+neededWidth > width { + if lipgloss.Width(currentLine) < width { + currentLine += strings.Repeat(" ", width-lipgloss.Width(currentLine)) + } + lines = append(lines, currentLine) + if len(lines) >= maxLines { + break + } + currentLine = " " + currentWidth = 1 + } + + if len(currentLine) > 1 { + currentLine += strings.Repeat(" ", itemSpacing) + currentWidth += itemSpacing + } + currentLine += displayName + currentWidth += sugWidth + + if len(lines) >= maxLines { + break + } + } + + if len(lines) < maxLines && currentWidth > 1 { + if lipgloss.Width(currentLine) < width { + currentLine += strings.Repeat(" ", width-lipgloss.Width(currentLine)) + } + lines = append(lines, currentLine) + } + + return lines +} + +func CurrentWord(input string, pos int) (int, int, string) { + if pos > len(input) { + pos = len(input) + } + start := strings.LastIndexFunc(input[:pos], func(r rune) bool { + return strings.ContainsRune(" \t\n\r", r) + }) + if start == -1 { + start = 0 + } else { + start++ + } + end := pos + for end < len(input) && !strings.ContainsRune(" \t\n\r", rune(input[end])) { + end++ + } + return start, end, input[start:end] +} + +func CompletePathCandidates(prefix, dir string, dirsOnly bool) []string { + if prefix == "~" { + return []string{"~/"} + } + + rawDir, base := filepath.Split(prefix) + if rawDir == "" { + rawDir = "." + } + + expandedRawDir := rawDir + if strings.Contains(rawDir, "~") { + expandedRawDir = ExpandTilde(rawDir) + } + + scanDir := expandedRawDir + if !filepath.IsAbs(expandedRawDir) && expandedRawDir != "." { + scanDir = filepath.Join(dir, expandedRawDir) + } + + entries, err := os.ReadDir(scanDir) + if err != nil { + return nil + } + + var candidates []string + for _, entry := range entries { + if dirsOnly && !entry.IsDir() { + continue + } + if base != "" && !strings.HasPrefix(entry.Name(), base) { + continue + } + candidate := filepath.Join(rawDir, entry.Name()) + if rawDir == "." { + candidate = entry.Name() + } + if entry.IsDir() { + candidate += string(os.PathSeparator) + } + candidates = append(candidates, candidate) + } + sort.Strings(candidates) + return candidates +} + +func CompleteExecCandidates(prefix string) []string { + if strings.Contains(prefix, string(filepath.Separator)) { + rawDir, base := filepath.Split(prefix) + if rawDir == "" { + rawDir = "." + } + + expandedRawDir := rawDir + if strings.Contains(rawDir, "~") { + expandedRawDir = ExpandTilde(rawDir) + } + + scanDir := expandedRawDir + if !filepath.IsAbs(expandedRawDir) && expandedRawDir != "." { + scanDir = filepath.Join(".", expandedRawDir) + } + + entries, err := os.ReadDir(scanDir) + if err != nil { + return nil + } + + var out []string + for _, entry := range entries { + if base != "" && !strings.HasPrefix(entry.Name(), base) { + continue + } + if entry.IsDir() { + continue + } + info, err := entry.Info() + if err != nil { + continue + } + if !info.Mode().IsRegular() || info.Mode().Perm()&0111 == 0 { + continue + } + + candidate := filepath.Join(rawDir, entry.Name()) + out = append(out, candidate) + } + sort.Strings(out) + return out + } + + pathEnv := os.Getenv("PATH") + execCacheMu.Lock() + if execCandidateCache == nil || pathEnv != execPathEnv { + execPathEnv = pathEnv + execCandidateCache = buildExecCache(pathEnv) + } + candidates := execCandidateCache + execCacheMu.Unlock() + + if prefix == "" { + return append([]string(nil), candidates...) + } + + var out []string + for _, candidate := range candidates { + if strings.HasPrefix(candidate, prefix) { + out = append(out, candidate) + } + } + return out +} + +func buildExecCache(pathEnv string) []string { + paths := filepath.SplitList(pathEnv) + seen := make(map[string]struct{}) + var candidates []string + for _, p := range paths { + if p == "" { + p = "." + } + entries, err := os.ReadDir(p) + if err != nil { + continue + } + for _, entry := range entries { + name := entry.Name() + if strings.Contains(name, "/") { + continue + } + if _, ok := seen[name]; ok { + continue + } + info, err := entry.Info() + if err != nil { + continue + } + if info.Mode().IsRegular() && info.Mode().Perm()&0111 != 0 { + seen[name] = struct{}{} + candidates = append(candidates, name) + } + } + } + sort.Strings(candidates) + return candidates +} diff --git a/internal/ui/dialog/dialog.go b/internal/ui/dialog/dialog.go index 6042a40..ac69e61 100644 --- a/internal/ui/dialog/dialog.go +++ b/internal/ui/dialog/dialog.go @@ -6,6 +6,8 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/kooler/MiddayCommander/internal/ui/completion" + "github.com/kooler/MiddayCommander/internal/ui/overlay" "github.com/kooler/MiddayCommander/internal/ui/theme" ) @@ -36,8 +38,10 @@ type Model struct { tag string // passed back in Result // Input dialog - input string - inputPos int + input string + inputPos int + basePath string + suggestions []string // Progress dialog progress float64 @@ -63,6 +67,10 @@ func NewConfirm(title, message, tag string) Model { // NewInput creates a text input dialog. func NewInput(title, message, defaultValue, tag string) Model { + return NewInputWithBase(title, message, defaultValue, tag, "") +} + +func NewInputWithBase(title, message, defaultValue, tag, basePath string) Model { return Model{ kind: KindInput, title: title, @@ -70,6 +78,7 @@ func NewInput(title, message, defaultValue, tag string) Model { tag: tag, input: defaultValue, inputPos: len(defaultValue), + basePath: basePath, width: 50, } } @@ -146,15 +155,21 @@ func (m *Model) updateInput(msg tea.KeyMsg) tea.Cmd { case "esc": m.done = true m.result = Result{Kind: KindInput, Confirmed: false, Tag: m.tag} + case "tab": + if m.tag == "goto" { + m.completeGoToPath() + } case "backspace": if m.inputPos > 0 { m.input = m.input[:m.inputPos-1] + m.input[m.inputPos:] m.inputPos-- } + m.updateSuggestions() case "delete": if m.inputPos < len(m.input) { m.input = m.input[:m.inputPos] + m.input[m.inputPos+1:] } + m.updateSuggestions() case "left": if m.inputPos > 0 { m.inputPos-- @@ -171,11 +186,37 @@ func (m *Model) updateInput(msg tea.KeyMsg) tea.Cmd { if len(msg.String()) == 1 && msg.String()[0] >= 32 { m.input = m.input[:m.inputPos] + msg.String() + m.input[m.inputPos:] m.inputPos++ + m.updateSuggestions() } } return nil } +func (m *Model) updateSuggestions() { + if m.tag != "goto" { + m.suggestions = nil + return + } + m.suggestions = completion.CompletePathCandidates(m.input, m.basePath, true) +} + +func (m *Model) completeGoToPath() { + candidates := completion.CompletePathCandidates(m.input, m.basePath, true) + m.suggestions = candidates + if len(candidates) == 0 { + return + } + common := completion.CommonPrefix(candidates) + if len(common) > len(m.input) { + m.input = common + } + if len(candidates) == 1 { + m.input = candidates[0] + } + m.inputPos = len(m.input) + m.updateSuggestions() +} + func (m *Model) updateError(msg tea.KeyMsg) tea.Cmd { switch msg.String() { case "enter", "esc", "q": @@ -202,6 +243,11 @@ func (m Model) BoxSize(screenWidth, screenHeight int) (int, int) { var msgLines int if m.kind == KindInput { msgLines = 1 // label + input on one line + if len(m.suggestions) > 0 && m.tag == "goto" { + // For GoTo, format suggestions compactly (multiple per line) + formatted := completion.FormatSuggestions(m.suggestions, innerW-2, 6, true) + msgLines += len(formatted) + } } else { msgLines = len(wrapText(m.message, innerW-2)) } @@ -292,6 +338,15 @@ func (m Model) View(th theme.Theme, screenWidth, screenHeight int) string { } contentLines = append(contentLines, line) + if m.kind == KindInput && len(m.suggestions) > 0 && m.tag == "goto" { + // Format suggestions compactly (multiple per line) like Ctrl+R + formatted := completion.FormatSuggestions(m.suggestions, innerW-2, 6, true) + for _, suggLine := range formatted { + sugLine := completion.PadOrTrim(suggLine, innerW-1) + contentLines = append(contentLines, bgStyle.Render(" "+sugLine)) + } + } + default: // Message on its own line(s) for non-input dialogs for _, msgLine := range wrapText(m.message, innerW-2) {