From 2a4a9e0d113a64071526d9f0980f6f4507af3f6d Mon Sep 17 00:00:00 2001 From: Ruslan Huzii Date: Sat, 4 Apr 2026 17:21:54 +0300 Subject: [PATCH 01/13] feat: add Ctrl+R autocomplete for command execution overlay --- internal/ui/cmdexec/cmdexec.go | 170 +++++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) diff --git a/internal/ui/cmdexec/cmdexec.go b/internal/ui/cmdexec/cmdexec.go index 27d4862..2c5be4a 100644 --- a/internal/ui/cmdexec/cmdexec.go +++ b/internal/ui/cmdexec/cmdexec.go @@ -3,8 +3,12 @@ package cmdexec import ( "bytes" "fmt" + "os" "os/exec" + "path/filepath" + "sort" "strings" + "unicode" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -31,6 +35,7 @@ type Model struct { outputOffset int running bool dir string + suggestions []string width int height int } @@ -92,14 +97,19 @@ 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": + return m.completeCurrentWord(), nil + case "backspace": if m.inputPos > 0 { m.input = m.input[:m.inputPos-1] + m.input[m.inputPos:] m.inputPos-- } + m.suggestions = nil case "delete": if m.inputPos < len(m.input) { @@ -110,17 +120,21 @@ func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) { if m.inputPos > 0 { m.inputPos-- } + m.suggestions = nil case "right": if m.inputPos < len(m.input) { m.inputPos++ } + m.suggestions = nil case "home": m.inputPos = 0 + m.suggestions = nil case "end": m.inputPos = len(m.input) + m.suggestions = nil case "up": if m.outputOffset > 0 { @@ -157,6 +171,7 @@ func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) { if len(s) == 1 && s[0] >= 32 { m.input = m.input[:m.inputPos] + s + m.input[m.inputPos:] m.inputPos++ + m.suggestions = nil } } @@ -261,6 +276,8 @@ func (m Model) View(th theme.Theme, screenWidth, screenHeight int) string { } contentLines = append(contentLines, rendered) } + } else if len(m.suggestions) > 0 { + contentLines = append(contentLines, dimStyle.Render(truncOrPad(" Suggestions: "+strings.Join(m.suggestions, ", "), innerW))) } else { hint := dimStyle.Render(" Type a command and press Enter") hintWidth := lipgloss.Width(hint) @@ -302,3 +319,156 @@ func runCommandCmd(dir, command string) tea.Cmd { return CommandDoneMsg{Output: buf.String(), Err: err} } } + +func (m Model) completeCurrentWord() Model { + start, end, prefix := currentWord(m.input, m.inputPos) + if prefix == "" { + return m + } + + candidates := completeCandidates(prefix, m.dir) + m.suggestions = candidates + if len(candidates) == 0 { + return m + } + + common := commonPrefix(candidates) + if len(common) > len(prefix) { + m.input = m.input[:start] + common + m.input[end:] + m.inputPos = start + len(common) + } + + if len(candidates) == 1 { + m.input = m.input[:start] + candidates[0] + m.input[end:] + m.inputPos = start + len(candidates[0]) + } + + return m +} + +func currentWord(input string, pos int) (int, int, string) { + if pos > len(input) { + pos = len(input) + } + start := strings.LastIndexFunc(input[:pos], unicode.IsSpace) + if start == -1 { + start = 0 + } else { + start++ + } + end := pos + for end < len(input) && !unicode.IsSpace(rune(input[end])) { + end++ + } + return start, end, input[start:end] +} + +func completeCandidates(prefix, dir string) []string { + candidates := make(map[string]struct{}) + for _, c := range completePathCandidates(prefix, dir) { + candidates[c] = struct{}{} + } + if !strings.Contains(prefix, "/") { + for _, c := range completeExecCandidates(prefix) { + candidates[c] = struct{}{} + } + } + + var out []string + for c := range candidates { + out = append(out, c) + } + sort.Strings(out) + return out +} + +func completePathCandidates(prefix, dir string) []string { + rawDir, base := filepath.Split(prefix) + if rawDir == "" { + rawDir = "." + } + scanDir := rawDir + if !filepath.IsAbs(rawDir) { + scanDir = filepath.Join(dir, rawDir) + } + entries, err := os.ReadDir(scanDir) + if err != nil { + return nil + } + + var candidates []string + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), base) { + 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 { + pathEnv := os.Getenv("PATH") + paths := filepath.SplitList(pathEnv) + seen := make(map[string]struct{}) + var candidates []string + + for _, p := range paths { + entries, err := os.ReadDir(p) + if err != nil { + continue + } + for _, entry := range entries { + name := entry.Name() + if !strings.HasPrefix(name, prefix) { + continue + } + if _, ok := seen[name]; ok { + continue + } + path := filepath.Join(p, name) + info, err := os.Stat(path) + 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 +} + +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 truncOrPad(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)) +} From 2c6a33bd6fc4c5b4efc934903bd84c795e823fd1 Mon Sep 17 00:00:00 2001 From: Ruslan Huzii Date: Sat, 4 Apr 2026 17:35:50 +0300 Subject: [PATCH 02/13] fix: preserve cursor character in Ctrl+R autocomplete input display --- internal/ui/cmdexec/cmdexec.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/ui/cmdexec/cmdexec.go b/internal/ui/cmdexec/cmdexec.go index 2c5be4a..0fadb9e 100644 --- a/internal/ui/cmdexec/cmdexec.go +++ b/internal/ui/cmdexec/cmdexec.go @@ -231,7 +231,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 + "█" } From 0860b612ab049d75c1aac1a9cab833aecb60600f Mon Sep 17 00:00:00 2001 From: Ruslan Huzii Date: Sat, 4 Apr 2026 17:58:58 +0300 Subject: [PATCH 03/13] fix: prevent tab completion of subdirectories when path ends with / --- internal/ui/cmdexec/cmdexec.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/ui/cmdexec/cmdexec.go b/internal/ui/cmdexec/cmdexec.go index 0fadb9e..63e3485 100644 --- a/internal/ui/cmdexec/cmdexec.go +++ b/internal/ui/cmdexec/cmdexec.go @@ -384,6 +384,9 @@ func completeCandidates(prefix, dir string) []string { func completePathCandidates(prefix, dir string) []string { rawDir, base := filepath.Split(prefix) + if base == "" { + return nil + } if rawDir == "" { rawDir = "." } From 8f11d93e2f26ace382571945b4ad38764f754160 Mon Sep 17 00:00:00 2001 From: Ruslan Huzii Date: Sat, 4 Apr 2026 18:18:00 +0300 Subject: [PATCH 04/13] fix: preserve cursor position relative to word end during tab completion --- internal/ui/cmdexec/cmdexec.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/ui/cmdexec/cmdexec.go b/internal/ui/cmdexec/cmdexec.go index 63e3485..b8107ce 100644 --- a/internal/ui/cmdexec/cmdexec.go +++ b/internal/ui/cmdexec/cmdexec.go @@ -335,12 +335,12 @@ func (m Model) completeCurrentWord() Model { common := commonPrefix(candidates) if len(common) > len(prefix) { m.input = m.input[:start] + common + m.input[end:] - m.inputPos = start + len(common) + m.inputPos = start + len(common) - (end - m.inputPos) } if len(candidates) == 1 { m.input = m.input[:start] + candidates[0] + m.input[end:] - m.inputPos = start + len(candidates[0]) + m.inputPos = start + len(candidates[0]) - (end - m.inputPos) } return m From 5446799ec68dd9d63977d9e0687ef1c34d85f5c1 Mon Sep 17 00:00:00 2001 From: Ruslan Huzii Date: Sat, 4 Apr 2026 18:33:10 +0300 Subject: [PATCH 05/13] fix: prioritize path completion over exec and exclude exec with / --- internal/ui/cmdexec/cmdexec.go | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/internal/ui/cmdexec/cmdexec.go b/internal/ui/cmdexec/cmdexec.go index b8107ce..002c28b 100644 --- a/internal/ui/cmdexec/cmdexec.go +++ b/internal/ui/cmdexec/cmdexec.go @@ -364,14 +364,17 @@ func currentWord(input string, pos int) (int, int, string) { } func completeCandidates(prefix, dir string) []string { + pathCandidates := completePathCandidates(prefix, dir) + execCandidates := []string{} + if len(pathCandidates) == 0 && !strings.Contains(prefix, "/") { + execCandidates = completeExecCandidates(prefix) + } candidates := make(map[string]struct{}) - for _, c := range completePathCandidates(prefix, dir) { + for _, c := range pathCandidates { candidates[c] = struct{}{} } - if !strings.Contains(prefix, "/") { - for _, c := range completeExecCandidates(prefix) { - candidates[c] = struct{}{} - } + for _, c := range execCandidates { + candidates[c] = struct{}{} } var out []string @@ -432,6 +435,9 @@ func completeExecCandidates(prefix string) []string { if !strings.HasPrefix(name, prefix) { continue } + if strings.Contains(name, "/") { + continue + } if _, ok := seen[name]; ok { continue } From 32d6d9549ee040af540d7488442de948b7d02bac Mon Sep 17 00:00:00 2001 From: Ruslan Huzii Date: Sat, 4 Apr 2026 18:45:41 +0300 Subject: [PATCH 06/13] fix: allow path completion after trailing slash and list subdirectories for empty base --- internal/ui/cmdexec/cmdexec.go | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/internal/ui/cmdexec/cmdexec.go b/internal/ui/cmdexec/cmdexec.go index 002c28b..68e3a3b 100644 --- a/internal/ui/cmdexec/cmdexec.go +++ b/internal/ui/cmdexec/cmdexec.go @@ -387,9 +387,6 @@ func completeCandidates(prefix, dir string) []string { func completePathCandidates(prefix, dir string) []string { rawDir, base := filepath.Split(prefix) - if base == "" { - return nil - } if rawDir == "" { rawDir = "." } @@ -404,16 +401,17 @@ func completePathCandidates(prefix, dir string) []string { var candidates []string for _, entry := range entries { - if strings.HasPrefix(entry.Name(), base) { - candidate := filepath.Join(rawDir, entry.Name()) - if rawDir == "." { - candidate = entry.Name() - } - if entry.IsDir() { - candidate += string(os.PathSeparator) - } - candidates = append(candidates, candidate) + 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 From a9006af5c668d422d6cdeda8ae5ea5b44d4296ca Mon Sep 17 00:00:00 2001 From: Ruslan Huzii Date: Sat, 4 Apr 2026 20:41:30 +0300 Subject: [PATCH 07/13] feat: add smart autocomplete for command execution and path navigation - Add Ctrl+R command execution dialog with path/command autocomplete - Add Ctrl+G Go to path dialog with directory-only suggestions - Implement multi-line compact suggestion display (multiple items per line) - Add Tab completion with smart prefix matching and common prefix expansion - Support tilde (~) expansion for home directory paths - Display suggestion basenames for better readability - Add exec-only mode toggle in command overlay (Ctrl+G) - Update documentation and configuration examples - Format code with gofmt for consistency --- README.md | 4 +- config.example.toml | 4 + internal/ui/cmdexec/cmdexec.go | 180 +++++++++++++++++++++++++--- internal/ui/dialog/dialog.go | 210 ++++++++++++++++++++++++++++++++- 4 files changed, 379 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 531b9ee..75ff79e 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 @@ -97,7 +98,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) | | `Ctrl-P` | Fuzzy finder | | `Ctrl-B` | Bookmarks | | `Ctrl-T` | Theme picker (live preview) | diff --git a/config.example.toml b/config.example.toml index 9ec34c0..f3cc928 100644 --- a/config.example.toml +++ b/config.example.toml @@ -46,3 +46,7 @@ select_down = "shift+down" # Search quick_search = "ctrl+s" + +# Dialogs +goto = "ctrl+g" +cmd_exec = "ctrl+r" diff --git a/internal/ui/cmdexec/cmdexec.go b/internal/ui/cmdexec/cmdexec.go index 68e3a3b..89d646a 100644 --- a/internal/ui/cmdexec/cmdexec.go +++ b/internal/ui/cmdexec/cmdexec.go @@ -36,6 +36,7 @@ type Model struct { running bool dir string suggestions []string + execOnly bool width int height int } @@ -104,37 +105,42 @@ func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) { case "tab": return m.completeCurrentWord(), nil + case "ctrl+g": + m.execOnly = true + return m.updateSuggestions(), nil + case "backspace": if m.inputPos > 0 { m.input = m.input[:m.inputPos-1] + m.input[m.inputPos:] m.inputPos-- } - m.suggestions = nil + m = m.updateSuggestions() case "delete": if m.inputPos < len(m.input) { m.input = m.input[:m.inputPos] + m.input[m.inputPos+1:] } + m = m.updateSuggestions() case "left": if m.inputPos > 0 { m.inputPos-- } - m.suggestions = nil + m = m.updateSuggestions() case "right": if m.inputPos < len(m.input) { m.inputPos++ } - m.suggestions = nil + m = m.updateSuggestions() case "home": m.inputPos = 0 - m.suggestions = nil + m = m.updateSuggestions() case "end": m.inputPos = len(m.input) - m.suggestions = nil + m = m.updateSuggestions() case "up": if m.outputOffset > 0 { @@ -169,9 +175,14 @@ 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.suggestions = nil + m = m.updateSuggestions() } } @@ -277,7 +288,14 @@ func (m Model) View(th theme.Theme, screenWidth, screenHeight int) string { contentLines = append(contentLines, rendered) } } else if len(m.suggestions) > 0 { - contentLines = append(contentLines, dimStyle.Render(truncOrPad(" Suggestions: "+strings.Join(m.suggestions, ", "), innerW))) + 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) @@ -326,26 +344,65 @@ func (m Model) completeCurrentWord() Model { return m } - candidates := completeCandidates(prefix, m.dir) + candidates := completeCandidates(prefix, m.dir, m.execOnly) m.suggestions = candidates if len(candidates) == 0 { return m } + didComplete := false common := commonPrefix(candidates) if len(common) > len(prefix) { - m.input = m.input[:start] + common + m.input[end:] - m.inputPos = start + len(common) - (end - m.inputPos) + m.input = m.input[:start] + mergeCompletion(common, m.input[end:]) + m.inputPos = clamp(start+len(common)-(end-m.inputPos), len(m.input)) + didComplete = true } if len(candidates) == 1 { - m.input = m.input[:start] + candidates[0] + m.input[end:] - m.inputPos = start + len(candidates[0]) - (end - m.inputPos) + m.input = m.input[:start] + mergeCompletion(candidates[0], m.input[end:]) + m.inputPos = clamp(start+len(candidates[0])-(end-m.inputPos), len(m.input)) + didComplete = true + } + + if didComplete { + m.suggestions = nil + } + return m +} + +func clamp(pos, length int) int { + if pos < 0 { + return 0 + } + if pos > length { + return length } + return pos +} +func (m Model) updateSuggestions() Model { + _, _, prefix := 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 currentWord(input string, pos int) (int, int, string) { if pos > len(input) { pos = len(input) @@ -363,7 +420,11 @@ func currentWord(input string, pos int) (int, int, string) { return start, end, input[start:end] } -func completeCandidates(prefix, dir string) []string { +func completeCandidates(prefix, dir string, execOnly bool) []string { + if execOnly { + return completeExecCandidates(prefix) + } + pathCandidates := completePathCandidates(prefix, dir) execCandidates := []string{} if len(pathCandidates) == 0 && !strings.Contains(prefix, "/") { @@ -385,14 +446,42 @@ func completeCandidates(prefix, dir string) []string { return out } +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 completePathCandidates(prefix, dir string) []string { + if prefix == "~" { + return []string{"~/"} + } + rawDir, base := filepath.Split(prefix) if rawDir == "" { rawDir = "." } - scanDir := rawDir - if !filepath.IsAbs(rawDir) { - scanDir = filepath.Join(dir, rawDir) + + // Expand ~ in both rawDir and display + 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 { @@ -479,3 +568,62 @@ func truncOrPad(s string, width int) string { } return s + strings.Repeat(" ", width-lipgloss.Width(s)) } + +func formatSuggestions(suggestions []string, width, maxLines int) []string { + if len(suggestions) == 0 { + return nil + } + + var lines []string + currentLine := " " + currentWidth := 1 + + // Estimate padding needed (2 spaces between items) + itemSpacing := 2 + + for i, sug := range suggestions { + // Display basename for readability, but use full path for completion + displayName := filepath.Base(strings.TrimRight(sug, "/")) + sugWidth := lipgloss.Width(displayName) + neededWidth := sugWidth + itemSpacing + if i == 0 { + neededWidth = sugWidth + 1 // Just space after first item + } + + // If adding this suggestion would exceed width, start a new line + if currentWidth+neededWidth > width { + // Pad the current line to 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 += " " + currentWidth += 2 + } + currentLine += displayName + currentWidth += sugWidth + + if len(lines) >= maxLines { + // Truncate current suggestion if we're at max lines + break + } + } + + // Add the last line if there's content and we haven't hit max lines + if len(lines) < maxLines && currentWidth > 1 { + if lipgloss.Width(currentLine) < width { + currentLine += strings.Repeat(" ", width-lipgloss.Width(currentLine)) + } + lines = append(lines, currentLine) + } + + return lines +} diff --git a/internal/ui/dialog/dialog.go b/internal/ui/dialog/dialog.go index 6042a40..eddf4c3 100644 --- a/internal/ui/dialog/dialog.go +++ b/internal/ui/dialog/dialog.go @@ -1,6 +1,9 @@ package dialog import ( + "os" + "path/filepath" + "sort" "strings" tea "github.com/charmbracelet/bubbletea" @@ -36,8 +39,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 +68,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,10 +79,147 @@ func NewInput(title, message, defaultValue, tag string) Model { tag: tag, input: defaultValue, inputPos: len(defaultValue), + basePath: basePath, width: 50, } } +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 completeDialogPathCandidates(prefix, dir string) []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 { + // Only show directories for GoTo dialog + if !entry.IsDir() { + continue + } + if base != "" && !strings.HasPrefix(entry.Name(), base) { + continue + } + candidate := filepath.Join(rawDir, entry.Name()) + if rawDir == "." { + candidate = entry.Name() + } + candidate += string(os.PathSeparator) + candidates = append(candidates, candidate) + } + sort.Strings(candidates) + return candidates +} + +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 formatDialogSuggestions(suggestions []string, width, maxLines int) []string { + if len(suggestions) == 0 { + return nil + } + + var lines []string + currentLine := " " + currentWidth := 1 + + // Spacing between suggestions + itemSpacing := 2 + + for i, sug := range suggestions { + // Display basename for readability + displayName := filepath.Base(strings.TrimRight(sug, "/")) + sugWidth := lipgloss.Width(displayName) + neededWidth := sugWidth + itemSpacing + if i == 0 { + neededWidth = sugWidth + 1 + } + + // If adding this suggestion would exceed width, start a new line + if currentWidth+neededWidth > width { + // Pad the current line to 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 += " " + currentWidth += 2 + } + currentLine += displayName + currentWidth += sugWidth + + if len(lines) >= maxLines { + break + } + } + + // Add the last line if there's content + if len(currentLine) > 1 && len(lines) < maxLines { + if lipgloss.Width(currentLine) < width { + currentLine += strings.Repeat(" ", width-lipgloss.Width(currentLine)) + } + lines = append(lines, currentLine) + } + + return lines +} + // NewError creates an error display dialog. func NewError(title, message string) Model { return Model{ @@ -146,36 +292,72 @@ 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-- } + m.updateSuggestions() case "right": if m.inputPos < len(m.input) { m.inputPos++ } + m.updateSuggestions() case "home": m.inputPos = 0 + m.updateSuggestions() case "end": m.inputPos = len(m.input) + m.updateSuggestions() default: 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 = completeDialogPathCandidates(m.input, m.basePath) +} + +func (m *Model) completeGoToPath() { + candidates := completeDialogPathCandidates(m.input, m.basePath) + m.suggestions = candidates + if len(candidates) == 0 { + return + } + common := 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 +384,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 := formatDialogSuggestions(m.suggestions, innerW-2, 6) + msgLines += len(formatted) + } } else { msgLines = len(wrapText(m.message, innerW-2)) } @@ -292,6 +479,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 := formatDialogSuggestions(m.suggestions, innerW-2, 6) + for _, suggLine := range formatted { + sugLine := 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) { @@ -360,6 +556,16 @@ func padRight(s string, width int) string { return s + strings.Repeat(" ", width-len(s)) } +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 wrapText(text string, width int) []string { if len(text) <= width { return []string{text} From 981a8f2498d73bcd630cbd6ea2d470055f209577 Mon Sep 17 00:00:00 2001 From: Ruslan Huzii Date: Sat, 4 Apr 2026 20:41:36 +0300 Subject: [PATCH 08/13] chore: add MiddayCommander binary to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 9b61b57..5f1da67 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .claude mdc +MiddayCommander dist/ From 574a6541cc0f5d63375c56133218c7724e1cf976 Mon Sep 17 00:00:00 2001 From: Ruslan Huzii Date: Sat, 4 Apr 2026 20:51:55 +0300 Subject: [PATCH 09/13] revert .gitignore changes --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index 5f1da67..9b61b57 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ .claude mdc -MiddayCommander dist/ From 4740f66b51a016ca2b30f2236e2ac7e982bf54e4 Mon Sep 17 00:00:00 2001 From: Ruslan Huzii Date: Sat, 11 Apr 2026 17:51:29 +0300 Subject: [PATCH 10/13] Add shared autocomplete helpers and fix command execution/autocomplete behavior --- README.md | 2 +- internal/app/app.go | 2 +- internal/ui/cmdexec/cmdexec.go | 239 ++++------------------- internal/ui/completion/completion.go | 281 +++++++++++++++++++++++++++ internal/ui/dialog/dialog.go | 27 +-- 5 files changed, 328 insertions(+), 223 deletions(-) create mode 100644 internal/ui/completion/completion.go diff --git a/README.md b/README.md index 75ff79e..3ddb145 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ The left panel opens in the current directory, the right panel in your home dire | `Tab` | Switch active panel | | `Ctrl-U` | Swap panels | | `Ctrl-G` | Go to path (with directory autocomplete) | -| `Ctrl-R` | Execute command (with path/command 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/internal/app/app.go b/internal/app/app.go index 983c286..7acc4a8 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -620,7 +620,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 89d646a..a954779 100644 --- a/internal/ui/cmdexec/cmdexec.go +++ b/internal/ui/cmdexec/cmdexec.go @@ -3,9 +3,7 @@ package cmdexec import ( "bytes" "fmt" - "os" "os/exec" - "path/filepath" "sort" "strings" "unicode" @@ -13,6 +11,7 @@ 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" ) @@ -103,10 +102,16 @@ func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) { } case "tab": + m.output = "" + m.outputLines = nil + m.outputOffset = 0 return m.completeCurrentWord(), nil - case "ctrl+g": - m.execOnly = true + case "ctrl+e": + m.output = "" + m.outputLines = nil + m.outputOffset = 0 + m.execOnly = !m.execOnly return m.updateSuggestions(), nil case "backspace": @@ -114,33 +119,35 @@ func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) { 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 { m.inputPos-- } - m = m.updateSuggestions() case "right": if m.inputPos < len(m.input) { m.inputPos++ } - m = m.updateSuggestions() case "home": m.inputPos = 0 - m = m.updateSuggestions() case "end": m.inputPos = len(m.input) - m = m.updateSuggestions() case "up": if m.outputOffset > 0 { @@ -182,6 +189,9 @@ func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) { } m.input = m.input[:m.inputPos] + s + m.input[m.inputPos:] m.inputPos++ + m.output = "" + m.outputLines = nil + m.outputOffset = 0 m = m.updateSuggestions() } } @@ -309,7 +319,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, @@ -339,7 +355,7 @@ func runCommandCmd(dir, command string) tea.Cmd { } func (m Model) completeCurrentWord() Model { - start, end, prefix := currentWord(m.input, m.inputPos) + start, end, prefix := completion.CurrentWord(m.input, m.inputPos) if prefix == "" { return m } @@ -350,23 +366,20 @@ func (m Model) completeCurrentWord() Model { return m } - didComplete := false - common := commonPrefix(candidates) - if len(common) > len(prefix) { - m.input = m.input[:start] + mergeCompletion(common, m.input[end:]) - m.inputPos = clamp(start+len(common)-(end-m.inputPos), len(m.input)) - didComplete = true - } - if len(candidates) == 1 { m.input = m.input[:start] + mergeCompletion(candidates[0], m.input[end:]) - m.inputPos = clamp(start+len(candidates[0])-(end-m.inputPos), len(m.input)) - didComplete = true + m.inputPos = start + len(candidates[0]) + m.suggestions = nil + return m } - if didComplete { + 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 } @@ -381,7 +394,7 @@ func clamp(pos, length int) int { } func (m Model) updateSuggestions() Model { - _, _, prefix := currentWord(m.input, m.inputPos) + _, _, prefix := completion.CurrentWord(m.input, m.inputPos) if prefix == "" { if m.execOnly { m.suggestions = completeCandidates(prefix, m.dir, m.execOnly) @@ -422,13 +435,13 @@ func currentWord(input string, pos int) (int, int, string) { func completeCandidates(prefix, dir string, execOnly bool) []string { if execOnly { - return completeExecCandidates(prefix) + return completion.CompleteExecCandidates(prefix) } - pathCandidates := completePathCandidates(prefix, dir) + pathCandidates := completion.CompletePathCandidates(prefix, dir, false) execCandidates := []string{} if len(pathCandidates) == 0 && !strings.Contains(prefix, "/") { - execCandidates = completeExecCandidates(prefix) + execCandidates = completion.CompleteExecCandidates(prefix) } candidates := make(map[string]struct{}) for _, c := range pathCandidates { @@ -446,184 +459,14 @@ func completeCandidates(prefix, dir string, execOnly bool) []string { return out } -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 completePathCandidates(prefix, dir string) []string { - if prefix == "~" { - return []string{"~/"} - } - - rawDir, base := filepath.Split(prefix) - if rawDir == "" { - rawDir = "." - } - - // Expand ~ in both rawDir and display - 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 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 { - pathEnv := os.Getenv("PATH") - paths := filepath.SplitList(pathEnv) - seen := make(map[string]struct{}) - var candidates []string - - for _, p := range paths { - entries, err := os.ReadDir(p) - if err != nil { - continue - } - for _, entry := range entries { - name := entry.Name() - if !strings.HasPrefix(name, prefix) { - continue - } - if strings.Contains(name, "/") { - continue - } - if _, ok := seen[name]; ok { - continue - } - path := filepath.Join(p, name) - info, err := os.Stat(path) - 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 -} - 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 + return completion.CommonPrefix(strs) } func truncOrPad(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)) + return completion.PadOrTrim(s, width) } func formatSuggestions(suggestions []string, width, maxLines int) []string { - if len(suggestions) == 0 { - return nil - } - - var lines []string - currentLine := " " - currentWidth := 1 - - // Estimate padding needed (2 spaces between items) - itemSpacing := 2 - - for i, sug := range suggestions { - // Display basename for readability, but use full path for completion - displayName := filepath.Base(strings.TrimRight(sug, "/")) - sugWidth := lipgloss.Width(displayName) - neededWidth := sugWidth + itemSpacing - if i == 0 { - neededWidth = sugWidth + 1 // Just space after first item - } - - // If adding this suggestion would exceed width, start a new line - if currentWidth+neededWidth > width { - // Pad the current line to 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 += " " - currentWidth += 2 - } - currentLine += displayName - currentWidth += sugWidth - - if len(lines) >= maxLines { - // Truncate current suggestion if we're at max lines - break - } - } - - // Add the last line if there's content and we haven't hit max lines - if len(lines) < maxLines && currentWidth > 1 { - if lipgloss.Width(currentLine) < width { - currentLine += strings.Repeat(" ", width-lipgloss.Width(currentLine)) - } - lines = append(lines, currentLine) - } - - return lines + 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 eddf4c3..d237109 100644 --- a/internal/ui/dialog/dialog.go +++ b/internal/ui/dialog/dialog.go @@ -9,6 +9,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" ) @@ -84,23 +86,6 @@ func NewInputWithBase(title, message, defaultValue, tag, basePath string) Model } } -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 completeDialogPathCandidates(prefix, dir string) []string { if prefix == "~" { return []string{"~/"} @@ -113,7 +98,7 @@ func completeDialogPathCandidates(prefix, dir string) []string { expandedRawDir := rawDir if strings.Contains(rawDir, "~") { - expandedRawDir = expandTilde(rawDir) + expandedRawDir = completion.ExpandTilde(rawDir) } scanDir := expandedRawDir @@ -311,18 +296,14 @@ func (m *Model) updateInput(msg tea.KeyMsg) tea.Cmd { if m.inputPos > 0 { m.inputPos-- } - m.updateSuggestions() case "right": if m.inputPos < len(m.input) { m.inputPos++ } - m.updateSuggestions() case "home": m.inputPos = 0 - m.updateSuggestions() case "end": m.inputPos = len(m.input) - m.updateSuggestions() default: if len(msg.String()) == 1 && msg.String()[0] >= 32 { m.input = m.input[:m.inputPos] + msg.String() + m.input[m.inputPos:] @@ -338,7 +319,7 @@ func (m *Model) updateSuggestions() { m.suggestions = nil return } - m.suggestions = completeDialogPathCandidates(m.input, m.basePath) + m.suggestions = completion.CompletePathCandidates(m.input, m.basePath, true) } func (m *Model) completeGoToPath() { From 537f88eba717334afc46a46de7bcd113ef8339a9 Mon Sep 17 00:00:00 2001 From: Ruslan Huzii Date: Sat, 11 Apr 2026 18:02:26 +0300 Subject: [PATCH 11/13] Resolve config.example.toml merge changes --- config.example.toml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/config.example.toml b/config.example.toml index f3cc928..d1aac85 100644 --- a/config.example.toml +++ b/config.example.toml @@ -40,7 +40,7 @@ end = "end" go_back = "backspace" # Selection -toggle_select = "ctrl+t" +toggle_select = "insert" select_up = "shift+up" select_down = "shift+down" @@ -49,4 +49,9 @@ quick_search = "ctrl+s" # Dialogs goto = "ctrl+g" +fuzzy_find = ["f9", "ctrl+p"] +bookmarks = ["f2", "ctrl+b"] +help = ["f1"] +theme_picker = "ctrl+t" cmd_exec = "ctrl+r" +toggle_hidden = "ctrl+h" From 30bc0a85fe244b05582ac55ae5d0359fb08eba45 Mon Sep 17 00:00:00 2001 From: Ruslan Huzii Date: Sat, 11 Apr 2026 18:35:05 +0300 Subject: [PATCH 12/13] Removed unused functions --- internal/ui/cmdexec/cmdexec.go | 36 ---------------------------------- 1 file changed, 36 deletions(-) diff --git a/internal/ui/cmdexec/cmdexec.go b/internal/ui/cmdexec/cmdexec.go index a954779..723178d 100644 --- a/internal/ui/cmdexec/cmdexec.go +++ b/internal/ui/cmdexec/cmdexec.go @@ -6,7 +6,6 @@ import ( "os/exec" "sort" "strings" - "unicode" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -383,16 +382,6 @@ func (m Model) completeCurrentWord() Model { return m } -func clamp(pos, length int) int { - if pos < 0 { - return 0 - } - if pos > length { - return length - } - return pos -} - func (m Model) updateSuggestions() Model { _, _, prefix := completion.CurrentWord(m.input, m.inputPos) if prefix == "" { @@ -416,23 +405,6 @@ func mergeCompletion(candidate, suffix string) string { return candidate + suffix } -func currentWord(input string, pos int) (int, int, string) { - if pos > len(input) { - pos = len(input) - } - start := strings.LastIndexFunc(input[:pos], unicode.IsSpace) - if start == -1 { - start = 0 - } else { - start++ - } - end := pos - for end < len(input) && !unicode.IsSpace(rune(input[end])) { - end++ - } - return start, end, input[start:end] -} - func completeCandidates(prefix, dir string, execOnly bool) []string { if execOnly { return completion.CompleteExecCandidates(prefix) @@ -459,14 +431,6 @@ func completeCandidates(prefix, dir string, execOnly bool) []string { return out } -func commonPrefix(strs []string) string { - return completion.CommonPrefix(strs) -} - -func truncOrPad(s string, width int) string { - return completion.PadOrTrim(s, width) -} - func formatSuggestions(suggestions []string, width, maxLines int) []string { return completion.FormatSuggestions(suggestions, width, maxLines, true) } From cff6faa0533278232f6e5344ce666eaef6f970d9 Mon Sep 17 00:00:00 2001 From: Ruslan Huzii Date: Sun, 12 Apr 2026 09:50:35 +0300 Subject: [PATCH 13/13] Addressing review comments --- internal/ui/dialog/dialog.go | 142 ++--------------------------------- 1 file changed, 5 insertions(+), 137 deletions(-) diff --git a/internal/ui/dialog/dialog.go b/internal/ui/dialog/dialog.go index d237109..ac69e61 100644 --- a/internal/ui/dialog/dialog.go +++ b/internal/ui/dialog/dialog.go @@ -1,9 +1,6 @@ package dialog import ( - "os" - "path/filepath" - "sort" "strings" tea "github.com/charmbracelet/bubbletea" @@ -86,125 +83,6 @@ func NewInputWithBase(title, message, defaultValue, tag, basePath string) Model } } -func completeDialogPathCandidates(prefix, dir string) []string { - if prefix == "~" { - return []string{"~/"} - } - - rawDir, base := filepath.Split(prefix) - if rawDir == "" { - rawDir = "." - } - - expandedRawDir := rawDir - if strings.Contains(rawDir, "~") { - expandedRawDir = completion.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 { - // Only show directories for GoTo dialog - if !entry.IsDir() { - continue - } - if base != "" && !strings.HasPrefix(entry.Name(), base) { - continue - } - candidate := filepath.Join(rawDir, entry.Name()) - if rawDir == "." { - candidate = entry.Name() - } - candidate += string(os.PathSeparator) - candidates = append(candidates, candidate) - } - sort.Strings(candidates) - return candidates -} - -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 formatDialogSuggestions(suggestions []string, width, maxLines int) []string { - if len(suggestions) == 0 { - return nil - } - - var lines []string - currentLine := " " - currentWidth := 1 - - // Spacing between suggestions - itemSpacing := 2 - - for i, sug := range suggestions { - // Display basename for readability - displayName := filepath.Base(strings.TrimRight(sug, "/")) - sugWidth := lipgloss.Width(displayName) - neededWidth := sugWidth + itemSpacing - if i == 0 { - neededWidth = sugWidth + 1 - } - - // If adding this suggestion would exceed width, start a new line - if currentWidth+neededWidth > width { - // Pad the current line to 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 += " " - currentWidth += 2 - } - currentLine += displayName - currentWidth += sugWidth - - if len(lines) >= maxLines { - break - } - } - - // Add the last line if there's content - if len(currentLine) > 1 && len(lines) < maxLines { - if lipgloss.Width(currentLine) < width { - currentLine += strings.Repeat(" ", width-lipgloss.Width(currentLine)) - } - lines = append(lines, currentLine) - } - - return lines -} - // NewError creates an error display dialog. func NewError(title, message string) Model { return Model{ @@ -323,12 +201,12 @@ func (m *Model) updateSuggestions() { } func (m *Model) completeGoToPath() { - candidates := completeDialogPathCandidates(m.input, m.basePath) + candidates := completion.CompletePathCandidates(m.input, m.basePath, true) m.suggestions = candidates if len(candidates) == 0 { return } - common := commonPrefix(candidates) + common := completion.CommonPrefix(candidates) if len(common) > len(m.input) { m.input = common } @@ -367,7 +245,7 @@ func (m Model) BoxSize(screenWidth, screenHeight int) (int, int) { msgLines = 1 // label + input on one line if len(m.suggestions) > 0 && m.tag == "goto" { // For GoTo, format suggestions compactly (multiple per line) - formatted := formatDialogSuggestions(m.suggestions, innerW-2, 6) + formatted := completion.FormatSuggestions(m.suggestions, innerW-2, 6, true) msgLines += len(formatted) } } else { @@ -462,9 +340,9 @@ func (m Model) View(th theme.Theme, screenWidth, screenHeight int) string { if m.kind == KindInput && len(m.suggestions) > 0 && m.tag == "goto" { // Format suggestions compactly (multiple per line) like Ctrl+R - formatted := formatDialogSuggestions(m.suggestions, innerW-2, 6) + formatted := completion.FormatSuggestions(m.suggestions, innerW-2, 6, true) for _, suggLine := range formatted { - sugLine := padOrTrim(suggLine, innerW-1) + sugLine := completion.PadOrTrim(suggLine, innerW-1) contentLines = append(contentLines, bgStyle.Render(" "+sugLine)) } } @@ -537,16 +415,6 @@ func padRight(s string, width int) string { return s + strings.Repeat(" ", width-len(s)) } -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 wrapText(text string, width int) []string { if len(text) <= width { return []string{text}