diff --git a/README.md b/README.md index 531b9ee..f83d4e2 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ Midday Commander (mdc) brings the classic dual-panel file management paradigm in - **Multi-file selection** - tag files with Insert or Shift+Arrow for batch operations - **Quick search** - start typing to jump to matching files instantly - **External editor/viewer** - opens files in `$EDITOR` and `$PAGER` +- **Execute files** - run executable files directly with Enter (configurable) +- **Terminal access** - open shell in current directory with Ctrl+O - **Mouse support** - clickable menu bar and panel interaction - **Go to path** - quickly jump to any directory with `~` expansion - **Single binary** - no runtime dependencies @@ -159,10 +161,14 @@ cp config.example.toml ~/.config/mdc/config.toml theme = "catppuccin-mocha" [behavior] -# What Enter does on a file: "edit" or "preview" +# What Enter does on a file: "edit", "preview", or "execute" enter_action = "edit" # What Space does on a file: "preview" or "edit" space_action = "preview" +# Whether to ask for confirmation before executing a file. +confirm_execute = true +# Whether to pause and wait after execution before returning to Midday Commander. +pause_after_execute = false [keys] quit = ["f10", "ctrl+c"] @@ -175,6 +181,7 @@ fuzzy_find = ["f9", "ctrl+p"] bookmarks = ["f2", "ctrl+b"] help = "f1" goto = "ctrl+g" +terminal = "ctrl+o" # ... all keys are configurable ``` diff --git a/config.example.toml b/config.example.toml index 9ec34c0..9a58f0d 100644 --- a/config.example.toml +++ b/config.example.toml @@ -8,10 +8,14 @@ theme = "catppuccin-mocha" # ─── Behavior ──────────────────────────────────── [behavior] -# What Enter does on a file: "edit" or "preview" +# What Enter does on a file: "edit", "preview", or "execute" enter_action = "edit" # What Space does on a file: "preview" or "edit" space_action = "preview" +# Whether to ask for confirmation before executing a file. +confirm_execute = true +# Whether to pause and wait after execution before returning to Midday Commander. +pause_after_execute = false # ─── Key Bindings ──────────────────────────────── # Each binding can be a single string or a list of strings. @@ -46,3 +50,12 @@ select_down = "shift+down" # Search quick_search = "ctrl+s" + +# Go to path +goto = "ctrl+g" +fuzzy_find = ["f9", "ctrl+p"] +bookmarks = ["f2", "ctrl+b"] +help = "f1" +theme_picker = "ctrl+t" +cmd_exec = "ctrl+r" +terminal = "ctrl+o" diff --git a/internal/app/app.go b/internal/app/app.go index 7d8b086..56902d4 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -36,12 +36,13 @@ const ( // Dialog tags identify which operation triggered the dialog. const ( - tagCopy = "copy" - tagMove = "move" - tagDelete = "delete" - tagMkdir = "mkdir" - tagRename = "rename" - tagGoTo = "goto" + tagCopy = "copy" + tagMove = "move" + tagDelete = "delete" + tagMkdir = "mkdir" + tagRename = "rename" + tagGoTo = "goto" + tagExecute = "execute" ) // Model is the root application model. @@ -71,8 +72,9 @@ type Model struct { bookmarkStore *bookmark.Store // Pending operation state (saved while dialog is open) - pendingSources []string - pendingDest string + pendingSources []string + pendingDest string + pendingExecutePath string // Double-Esc to quit lastEsc time.Time @@ -100,10 +102,10 @@ func New() Model { panelKM := panelKeyMapFromConfig(cfg.Keys) - left := panel.New(lfs, cwd, panelKM) + left := panel.New(lfs, cwd, panelKM, cfg) left.SetActive(true) - right := panel.New(lfs, home, panelKM) + right := panel.New(lfs, home, panelKM, cfg) th := theme.Default() if cfg.Theme != "" { @@ -253,6 +255,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case panel.OpenFileMsg: return m, m.fileActionCmd(msg.Path, m.cfg.Behavior.EnterAction) + case panel.ExecuteFileMsg: + if m.cfg.Behavior.ConfirmExecute == nil || *m.cfg.Behavior.ConfirmExecute { + m.pendingExecutePath = msg.Path + d := dialog.NewConfirm("Execute file", fmt.Sprintf("Run %s?", filepath.Base(msg.Path)), tagExecute) + m.dialog = &d + return m, nil + } + return m, executeFileCmd(msg.Path, m.activePanel().Path(), m.cfg.Behavior.PauseAfterExecute) + case panel.PreviewFileMsg: return m, m.fileActionCmd(msg.Path, m.cfg.Behavior.SpaceAction) @@ -437,6 +448,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, m.keyMap.CmdExec): return m.startCmdExec() + case key.Matches(msg, m.keyMap.Terminal): + return m, startTerminalCmd(m.activePanel().Path()) + case key.Matches(msg, m.keyMap.ToggleHidden): m.leftPanel.ToggleHidden() m.rightPanel.ToggleHidden() @@ -529,6 +543,8 @@ func (m Model) dispatchKey(raw string) (tea.Model, tea.Cmd) { return m.startThemePicker() case contains(cfg.CmdExec, raw): return m.startCmdExec() + case contains(cfg.Terminal, raw): + return m, startTerminalCmd(m.activePanel().Path()) } return m, nil } @@ -726,6 +742,10 @@ func (m Model) handleDialogResult(result dialog.Result) (tea.Model, tea.Cmd) { m.activePanel().SetPath(path) return m, m.activePanel().LoadDir() } + case tagExecute: + if result.Confirmed { + return m, executeFileCmd(m.pendingExecutePath, m.activePanel().Path(), m.cfg.Behavior.PauseAfterExecute) + } } return m, nil } diff --git a/internal/app/commands.go b/internal/app/commands.go index 3fa26fc..a8abd75 100644 --- a/internal/app/commands.go +++ b/internal/app/commands.go @@ -1,6 +1,7 @@ package app import ( + "fmt" "os" "os/exec" "path/filepath" @@ -77,6 +78,96 @@ func externalCmd(envVar, fallback, path string) tea.Cmd { }) } +func executeFileCmd(path string, dir string, pause bool) tea.Cmd { + if pause { + shell := os.Getenv("SHELL") + if shell == "" { + shell = "bash" + } + c := pauseExecutionCmd(shell, path) + c.Dir = dir + return tea.ExecProcess(c, func(err error) tea.Msg { + return externalDoneMsg{err: err} + }) + } + + c := exec.Command(path) + c.Dir = dir + return tea.ExecProcess(c, func(err error) tea.Msg { + return externalDoneMsg{err: err} + }) +} + +func pauseExecutionCmd(shellPath, path string) *exec.Cmd { + escapedPath := shellQuote(path) + shellName := filepath.Base(shellPath) + if shellName == "bash" || shellName == "zsh" { + script := fmt.Sprintf("status=0; %s || status=$?; printf '\nPress any key to continue...'; read -n1 -s; exit $status", escapedPath) + return exec.Command(shellPath, "-lc", script) + } + + script := fmt.Sprintf("status=0; %s || status=$?; printf '\nPress enter to continue...'; read -r; exit $status", escapedPath) + return exec.Command(shellPath, "-c", script) +} + +func shellQuote(path string) string { + return "'" + strings.ReplaceAll(path, "'", "'\\''") + "'" +} + +func startTerminalCmd(dir string) tea.Cmd { + shell := os.Getenv("SHELL") + if shell == "" { + shell = "bash" + } + c := interactiveShellCmd(shell) + c.Dir = dir + return tea.ExecProcess(c, func(err error) tea.Msg { + return externalDoneMsg{err: err} + }) +} + +func interactiveShellCmd(shellPath string) *exec.Cmd { + name := filepath.Base(shellPath) + switch name { + case "bash": + if rcFile, err := writeBashRc(); err == nil { + return exec.Command(shellPath, "--rcfile", rcFile, "-i") + } + case "zsh": + if dir, err := writeZshDir(); err == nil { + cmd := exec.Command(shellPath, "-i") + cmd.Env = append(os.Environ(), "ZDOTDIR="+dir) + return cmd + } + } + return exec.Command(shellPath, "-i") +} + +func writeBashRc() (string, error) { + f, err := os.CreateTemp("", "mdc-terminal-*.bashrc") + if err != nil { + return "", err + } + defer f.Close() + _, err = f.WriteString(`bind '"\C-o": "\C-d"'` + "\n") + if err != nil { + return "", err + } + return f.Name(), nil +} + +func writeZshDir() (string, error) { + dir, err := os.MkdirTemp("", "mdc-terminal-*") + if err != nil { + return "", err + } + path := filepath.Join(dir, ".zshrc") + if err := os.WriteFile(path, []byte("bindkey '^O' exit\n"), 0o600); err != nil { + return "", err + } + return dir, nil +} + // refreshBothPanels returns commands to reload both panels. func (m *Model) refreshBothPanels() tea.Cmd { return tea.Batch(m.leftPanel.LoadDir(), m.rightPanel.LoadDir()) diff --git a/internal/app/keymap.go b/internal/app/keymap.go index 43f670f..a816a12 100644 --- a/internal/app/keymap.go +++ b/internal/app/keymap.go @@ -8,44 +8,46 @@ import ( // KeyMap defines all global keybindings. type KeyMap struct { - Quit key.Binding - TogglePanel key.Binding - SwapPanels key.Binding - Copy key.Binding - Move key.Binding - Mkdir key.Binding - Delete key.Binding - Rename key.Binding - View key.Binding - Edit key.Binding - GoTo key.Binding - FuzzyFind key.Binding - Bookmarks key.Binding - Help key.Binding + Quit key.Binding + TogglePanel key.Binding + SwapPanels key.Binding + Copy key.Binding + Move key.Binding + Mkdir key.Binding + Delete key.Binding + Rename key.Binding + View key.Binding + Edit key.Binding + GoTo key.Binding + FuzzyFind key.Binding + Bookmarks key.Binding + Help key.Binding ThemePicker key.Binding CmdExec key.Binding + Terminal key.Binding ToggleHidden key.Binding } // KeyMapFromConfig builds the global keymap from config. func KeyMapFromConfig(keys config.KeyBindings) KeyMap { return KeyMap{ - Quit: binding(keys.Quit, "quit"), - TogglePanel: binding(keys.TogglePanel, "switch panel"), - SwapPanels: binding(keys.SwapPanels, "swap panels"), - Copy: binding(keys.Copy, "copy"), - Move: binding(keys.Move, "move"), - Mkdir: binding(keys.Mkdir, "mkdir"), - Delete: binding(keys.Delete, "delete"), - Rename: binding(keys.Rename, "rename"), - View: binding(keys.View, "view"), - Edit: binding(keys.Edit, "edit"), - GoTo: binding(keys.GoTo, "go to"), - FuzzyFind: binding(keys.FuzzyFind, "find"), - Bookmarks: binding(keys.Bookmarks, "bookmarks"), - Help: binding(keys.Help, "help"), + Quit: binding(keys.Quit, "quit"), + TogglePanel: binding(keys.TogglePanel, "switch panel"), + SwapPanels: binding(keys.SwapPanels, "swap panels"), + Copy: binding(keys.Copy, "copy"), + Move: binding(keys.Move, "move"), + Mkdir: binding(keys.Mkdir, "mkdir"), + Delete: binding(keys.Delete, "delete"), + Rename: binding(keys.Rename, "rename"), + View: binding(keys.View, "view"), + Edit: binding(keys.Edit, "edit"), + GoTo: binding(keys.GoTo, "go to"), + FuzzyFind: binding(keys.FuzzyFind, "find"), + Bookmarks: binding(keys.Bookmarks, "bookmarks"), + Help: binding(keys.Help, "help"), ThemePicker: binding(keys.ThemePicker, "themes"), CmdExec: binding(keys.CmdExec, "run cmd"), + Terminal: binding(keys.Terminal, "terminal"), ToggleHidden: binding(keys.ToggleHidden, "toggle hidden"), } } diff --git a/internal/config/config.go b/internal/config/config.go index 9db7825..c495fd7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,10 +20,14 @@ type Config struct { // BehaviorConfig controls configurable behaviors. type BehaviorConfig struct { - // What Enter does on a file: "edit" (default) or "preview" + // What Enter does on a file: "edit" (default), "preview", or "execute" EnterAction string `toml:"enter_action"` // What Space does on a file: "preview" (default) or "edit" SpaceAction string `toml:"space_action"` + // Whether to ask for confirmation before executing a file. + ConfirmExecute *bool `toml:"confirm_execute"` + // Whether to pause and wait after execution before returning to Midday Commander. + PauseAfterExecute bool `toml:"pause_after_execute"` } // KeyBindings defines all configurable key bindings. @@ -58,12 +62,13 @@ type KeyBindings struct { QuickSearch StringOrList `toml:"quick_search"` // Go to path - GoTo StringOrList `toml:"goto"` - FuzzyFind StringOrList `toml:"fuzzy_find"` - Bookmarks StringOrList `toml:"bookmarks"` - Help StringOrList `toml:"help"` + GoTo StringOrList `toml:"goto"` + FuzzyFind StringOrList `toml:"fuzzy_find"` + Bookmarks StringOrList `toml:"bookmarks"` + Help StringOrList `toml:"help"` ThemePicker StringOrList `toml:"theme_picker"` CmdExec StringOrList `toml:"cmd_exec"` + Terminal StringOrList `toml:"terminal"` ToggleHidden StringOrList `toml:"toggle_hidden"` } @@ -85,14 +90,20 @@ func (s *StringOrList) UnmarshalTOML(data any) error { } // Default returns a config with all defaults. +func boolPtr(v bool) *bool { + return &v +} + func Default() Config { keys := DefaultKeyBindings() normalizeAllKeys(&keys) return Config{ Theme: "", Behavior: BehaviorConfig{ - EnterAction: "edit", - SpaceAction: "preview", + EnterAction: "edit", + SpaceAction: "preview", + ConfirmExecute: boolPtr(true), + PauseAfterExecute: false, }, Keys: keys, } @@ -126,12 +137,13 @@ func DefaultKeyBindings() KeyBindings { QuickSearch: StringOrList{"ctrl+s"}, - GoTo: StringOrList{"ctrl+g"}, - FuzzyFind: StringOrList{"f9", "ctrl+p"}, - Bookmarks: StringOrList{"f2", "ctrl+b"}, - Help: StringOrList{"f1"}, + GoTo: StringOrList{"ctrl+g"}, + FuzzyFind: StringOrList{"f9", "ctrl+p"}, + Bookmarks: StringOrList{"f2", "ctrl+b"}, + Help: StringOrList{"f1"}, ThemePicker: StringOrList{"ctrl+t"}, CmdExec: StringOrList{"ctrl+r"}, + Terminal: StringOrList{"ctrl+o"}, ToggleHidden: StringOrList{"ctrl+h"}, } } @@ -161,6 +173,10 @@ func Load() Config { if fileCfg.Behavior.SpaceAction != "" { cfg.Behavior.SpaceAction = fileCfg.Behavior.SpaceAction } + if fileCfg.Behavior.ConfirmExecute != nil { + cfg.Behavior.ConfirmExecute = fileCfg.Behavior.ConfirmExecute + } + cfg.Behavior.PauseAfterExecute = fileCfg.Behavior.PauseAfterExecute mergeKeys(&cfg.Keys, &fileCfg.Keys) normalizeAllKeys(&cfg.Keys) @@ -196,6 +212,7 @@ func mergeKeys(dst, src *KeyBindings) { mergeKey(&dst.Help, src.Help) mergeKey(&dst.ThemePicker, src.ThemePicker) mergeKey(&dst.CmdExec, src.CmdExec) + mergeKey(&dst.Terminal, src.Terminal) mergeKey(&dst.ToggleHidden, src.ToggleHidden) } @@ -253,6 +270,7 @@ func normalizeAllKeys(kb *KeyBindings) { normalizeSlice(&kb.Help) normalizeSlice(&kb.ThemePicker) normalizeSlice(&kb.CmdExec) + normalizeSlice(&kb.Terminal) normalizeSlice(&kb.ToggleHidden) } diff --git a/internal/ui/help/help.go b/internal/ui/help/help.go index ba502d1..8c08520 100644 --- a/internal/ui/help/help.go +++ b/internal/ui/help/help.go @@ -98,6 +98,7 @@ func (m Model) buildEntries() []entry { {"Quick search", fmtKeys(k.QuickSearch)}, {"Theme picker", fmtKeys(k.ThemePicker)}, {"Run command", fmtKeys(k.CmdExec)}, + {"Terminal", fmtKeys(k.Terminal)}, {"Help", fmtKeys(k.Help)}, {"Quit", fmtKeys(k.Quit)}, } diff --git a/internal/ui/panel/panel.go b/internal/ui/panel/panel.go index 101de02..00c03f6 100644 --- a/internal/ui/panel/panel.go +++ b/internal/ui/panel/panel.go @@ -8,6 +8,7 @@ import ( "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" + "github.com/kooler/MiddayCommander/internal/config" "github.com/kooler/MiddayCommander/internal/vfs" "github.com/kooler/MiddayCommander/internal/vfs/archive" ) @@ -43,6 +44,8 @@ type Model struct { active bool err error + cfg config.Config // application config + // Quick search state searching bool searchQuery string @@ -58,7 +61,7 @@ type Model struct { } // New creates a new panel browsing the given directory. -func New(filesystem vfs.FS, path string, km KeyMap) Model { +func New(filesystem vfs.FS, path string, km KeyMap, cfg config.Config) Model { return Model{ fs: filesystem, path: path, @@ -66,6 +69,7 @@ func New(filesystem vfs.FS, path string, km KeyMap) Model { sortMode: SortByName, showHidden: true, keyMap: km, + cfg: cfg, } } @@ -398,8 +402,12 @@ func (m *Model) handleEnter() tea.Cmd { } } - // Enter on file = open for edit + // Enter on file path := m.CurrentPath() + info := m.CurrentInfo() + if info != nil && isExecutable(info.Mode()) && m.cfg.Behavior.EnterAction == "execute" { + return func() tea.Msg { return ExecuteFileMsg{Path: path} } + } return func() tea.Msg { return OpenFileMsg{Path: path} } } @@ -519,6 +527,11 @@ type OpenFileMsg struct { Path string } +// ExecuteFileMsg is sent when the user wants to execute a file (Enter on executable file). +type ExecuteFileMsg struct { + Path string +} + // PreviewFileMsg is sent when the user wants to preview a file (Space on file). type PreviewFileMsg struct { Path string