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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- `mshFileManager` builtin: pops a starting directory from the stack, opens the file manager, and cds to the final directory on exit
- `msh fm` now accepts an optional starting directory argument
- Built-in file manager via `msh fm` subcommand and Ctrl-O in interactive mode
- Dual-pane layout with directory listing and file/directory preview
- Vim-style navigation (`j`/`k`, `h`/`l`, `gg`/`G`, Ctrl-u/Ctrl-d)
Expand Down
4 changes: 4 additions & 0 deletions doc/mshell.md
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,10 @@ end
- `readTsvFile`: Read a TSV file into list of list of strings. `(str -- [[str]])`
- `cd`: Change directory `(str -- )`
- `pwd`: Get current working directory `( -- str)`
- `mshFileManager`: Open the built-in file manager.
Pops a starting directory from the stack.
On exit, changes the working directory to the directory the user navigated to.
`(str -- )`
- `writeFile`: Write string to file (UTF-8). Overwrites file if it exists. `(str content str file -- )`
- `appendFile`: Append string to file (UTF-8). `(str content str file -- )`
- `fileSize`: Get size of file in bytes. Returns a Maybe in case file doesn't exist or other IO error. `(str -- Maybe int)`
Expand Down
1 change: 1 addition & 0 deletions mshell/BuiltInList.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ var BuiltInList = map[string]struct{}{
"mkdir": {},
"mkdirp": {},
"mod": {},
"mshFileManager": {},
"month": {},
"mv": {},
"nip": {},
Expand Down
18 changes: 18 additions & 0 deletions mshell/Evaluator.go
Original file line number Diff line number Diff line change
Expand Up @@ -2206,6 +2206,24 @@ MainLoop:
if !result.Success {
return result
}
} else if t.Lexeme == "mshFileManager" {
obj, err := stack.Pop()
if err != nil {
return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot do 'mshFileManager' on an empty stack.\n", t.Line, t.Column))
}

dir, err := obj.CastString()
if err != nil {
return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot use a %s as a starting directory for mshFileManager.\n", t.Line, t.Column, obj.TypeName()))
}

newDir := RunFileManagerBuiltin(dir)
if newDir != "" {
result, _, _, _ := state.ChangeDirectory(newDir)
if !result.Success {
return result
}
}
} else if t.Lexeme == "in" {
substring, stringOrDict, err := stack.Pop2(t)
if err != nil {
Expand Down
107 changes: 99 additions & 8 deletions mshell/FileManager.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,15 @@ type FileManager struct {
renaming bool
renameInput []rune
renameCursor int // cursor position within renameInput

// Status message (shown once at bottom, cleared on first keypress)
statusMsg string
}

// RunFileManager runs as a standalone subcommand (msh fm).
// TUI goes to /dev/tty, final directory is printed to stdout.
func RunFileManager() int {
// If startDir is non-empty and is a valid directory, it is used instead of cwd.
func RunFileManager(startDir string) int {
fm := &FileManager{}
fm.stdInFd = int(os.Stdin.Fd())

Expand Down Expand Up @@ -83,10 +87,23 @@ func RunFileManager() int {
fm.initUserInfo()
fm.bookmarks = loadBookmarks()

fm.currentDir, err = os.Getwd()
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting working directory: %s\n", err)
return 1
if startDir != "" {
if info, err := os.Stat(startDir); err == nil && info.IsDir() {
fm.currentDir, _ = filepath.Abs(startDir)
} else {
fm.statusMsg = fmt.Sprintf("Directory not found: %s", startDir)
fm.currentDir, err = os.Getwd()
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting working directory: %s\n", err)
return 1
}
}
} else {
fm.currentDir, err = os.Getwd()
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting working directory: %s\n", err)
return 1
}
}

fm.loadDirectory()
Expand Down Expand Up @@ -115,8 +132,9 @@ func RunFileManager() int {

// RunFileManagerInteractive runs from within the mshell interactive session.
// The terminal is already in raw mode from the interactive session.
// If startDir is non-empty and is a valid directory, it is used instead of cwd.
// Returns the directory the user was in when they quit.
func RunFileManagerInteractive(stdInFd int, oldState *term.State) string {
func RunFileManagerInteractive(stdInFd int, oldState *term.State, startDir string) string {
fm := &FileManager{}
fm.stdInFd = stdInFd
fm.oldState = *oldState
Expand All @@ -132,7 +150,16 @@ func RunFileManagerInteractive(stdInFd int, oldState *term.State) string {
fm.initUserInfo()
fm.bookmarks = loadBookmarks()

fm.currentDir, _ = os.Getwd()
if startDir != "" {
if info, err := os.Stat(startDir); err == nil && info.IsDir() {
fm.currentDir, _ = filepath.Abs(startDir)
} else {
fm.statusMsg = fmt.Sprintf("Directory not found: %s", startDir)
fm.currentDir, _ = os.Getwd()
}
} else {
fm.currentDir, _ = os.Getwd()
}
fm.loadDirectory()

// Terminal is already in raw mode from the interactive session.
Expand All @@ -146,6 +173,55 @@ func RunFileManagerInteractive(stdInFd int, oldState *term.State) string {
return fm.currentDir
}

// RunFileManagerBuiltin runs from a builtin call during evaluation.
// The terminal is in cooked mode, so this handles MakeRaw/Restore itself.
// If startDir is non-empty and is a valid directory, it is used instead of cwd.
// Returns the directory the user was in when they quit.
func RunFileManagerBuiltin(startDir string) string {
fm := &FileManager{}
fm.stdInFd = int(os.Stdin.Fd())
fm.ttyOut = os.Stdout

cols, rows, err := term.GetSize(int(os.Stdout.Fd()))
if err != nil {
return ""
}
fm.rows = rows
fm.cols = cols

fm.initUserInfo()
fm.bookmarks = loadBookmarks()

if startDir != "" {
if info, err := os.Stat(startDir); err == nil && info.IsDir() {
fm.currentDir, _ = filepath.Abs(startDir)
} else {
fm.statusMsg = fmt.Sprintf("Directory not found: %s", startDir)
fm.currentDir, _ = os.Getwd()
}
} else {
fm.currentDir, _ = os.Getwd()
}
fm.loadDirectory()

oldState, err := term.MakeRaw(fm.stdInFd)
if err != nil {
return ""
}
fm.oldState = *oldState

fm.ttyOut.WriteString("\033[?1049h\033[?25l")

defer func() {
fm.ttyOut.WriteString("\033[?25h\033[?1049l")
term.Restore(fm.stdInFd, &fm.oldState)
}()

fm.mainLoop()

return fm.currentDir
}

func (fm *FileManager) initUserInfo() {
fm.hostname, _ = os.Hostname()
if u, err := user.Current(); err == nil {
Expand Down Expand Up @@ -188,7 +264,7 @@ func (fm *FileManager) loadDirectory() {
}

func (fm *FileManager) visibleRows() int {
if fm.searching || fm.renaming {
if fm.searching || fm.renaming || fm.statusMsg != "" {
return fm.rows - 2 // header + bottom bar
}
return fm.rows - 1 // header only
Expand Down Expand Up @@ -481,6 +557,19 @@ func (fm *FileManager) render() {
buf.WriteString("\033[?25h")
}

// Status message at the bottom
if fm.statusMsg != "" && !fm.searching && !fm.renaming {
buf.WriteString("\r\n")
msg := fm.statusMsg
msgRunes := utf8.RuneCountInString(msg)
if msgRunes > fm.cols {
msg = string([]rune(msg)[:fm.cols])
}
buf.WriteString("\033[33m") // yellow
buf.WriteString(msg)
buf.WriteString("\033[0m")
}

// Bookmark overlay
if fm.pendingMark || fm.showingBookmarks {
fm.renderBookmarkOverlay(&buf)
Expand Down Expand Up @@ -644,6 +733,8 @@ func (fm *FileManager) handleInput() bool {
return false
}

fm.statusMsg = ""

if fm.searching {
return fm.handleSearchInput(buf, n)
}
Expand Down
14 changes: 11 additions & 3 deletions mshell/Main.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,11 @@ func main() {
}

if len(os.Args) >= 2 && os.Args[1] == "fm" {
os.Exit(RunFileManager())
startDir := ""
if len(os.Args) >= 3 {
startDir = os.Args[2]
}
os.Exit(RunFileManager(startDir))
return
}

Expand Down Expand Up @@ -3202,9 +3206,13 @@ func (state *TermState) HandleToken(token TerminalToken) (bool, error) {
} else if t.Char == 12 { // Ctrl-L
state.ClearScreen()
} else if t.Char == 15 { // Ctrl-O - file manager
newDir := RunFileManagerInteractive(state.stdInFd, &state.oldState)
newDir := RunFileManagerInteractive(state.stdInFd, &state.oldState, "")
if newDir != "" {
os.Chdir(newDir)
cwd, cwdErr := os.Getwd()
if err := os.Chdir(newDir); err == nil && cwdErr == nil {
os.Setenv("OLDPWD", cwd)
os.Setenv("PWD", newDir)
}
}
// Refresh terminal size
cols, rows, sizeErr := term.GetSize(int(os.Stdout.Fd()))
Expand Down