Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"]
Expand All @@ -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
```

Expand Down
15 changes: 14 additions & 1 deletion config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"
40 changes: 30 additions & 10 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 != "" {
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
91 changes: 91 additions & 0 deletions internal/app/commands.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package app

import (
"fmt"
"os"
"os/exec"
"path/filepath"
Expand Down Expand Up @@ -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())
Expand Down
58 changes: 30 additions & 28 deletions internal/app/keymap.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
}
}
Expand Down
Loading
Loading