diff --git a/.gitignore b/.gitignore index b19fe65..65aaa97 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ mshell/msh codex.log .gocache/ .gomodcache/ +.gopath/ .cache/ *.mshell_history diff --git a/CHANGELOG.md b/CHANGELOG.md index 0feb72c..d4d51bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Version-sorted entries, directories first and colored blue - Binary file detection for preview - Preview caching for fast scrolling + - Cut/copy/paste buffer (`d` cut, `yy` copy, `p` paste, `c` clear) shared across instances + - Delete to trash (`x`) with confirmation, using platform-native trash - `msh fm` prints final directory to stdout for `cd "$(msh fm)"` usage ## 0.10.0 - 2026-02-13 diff --git a/mshell/Evaluator.go b/mshell/Evaluator.go index 7e29319..e31a5bd 100644 --- a/mshell/Evaluator.go +++ b/mshell/Evaluator.go @@ -7146,26 +7146,54 @@ func (state *EvalState) RunPipeline(MShellPipe MShellPipe, context ExecuteContex } func CopyFile(source string, dest string) error { - // TODO: Fix this dumb copy code. - // Copy the file - input, err := os.Open(source) + srcInfo, err := os.Lstat(source) if err != nil { return err } - defer input.Close() - output, err := os.Create(dest) + // Handle symlinks + if srcInfo.Mode()&os.ModeSymlink != 0 { + target, err := os.Readlink(source) + if err != nil { + return err + } + return os.Symlink(target, dest) + } + + // Handle directories recursively + if srcInfo.IsDir() { + if err := os.MkdirAll(dest, srcInfo.Mode().Perm()); err != nil { + return err + } + entries, err := os.ReadDir(source) + if err != nil { + return err + } + for _, entry := range entries { + srcPath := filepath.Join(source, entry.Name()) + dstPath := filepath.Join(dest, entry.Name()) + if err := CopyFile(srcPath, dstPath); err != nil { + return err + } + } + return nil + } + + // Regular file: preserve permissions + input, err := os.Open(source) if err != nil { return err } - defer output.Close() + defer input.Close() - _, err = io.Copy(output, input) + output, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, srcInfo.Mode().Perm()) if err != nil { return err } + defer output.Close() - return nil + _, err = io.Copy(output, input) + return err } func VersionSortComparer(a_str string, b_str string) int { diff --git a/mshell/FileManager.go b/mshell/FileManager.go index eab2ebe..1f6db47 100644 --- a/mshell/FileManager.go +++ b/mshell/FileManager.go @@ -221,6 +221,12 @@ func (fm *FileManager) adjustScroll() { } func (fm *FileManager) leftPaneWidth() int { + _, clipPaths := loadClipboard() + clipSet := make(map[string]bool) + for _, p := range clipPaths { + clipSet[p] = true + } + maxLen := 0 visible := fm.visibleRows() end := fm.offset + visible @@ -233,6 +239,10 @@ func (fm *FileManager) leftPaneWidth() int { if fm.entries[i].IsDir() { nameLen++ // for '/' } + entryPath := filepath.Join(fm.currentDir, fm.entries[i].Name()) + if clipSet[entryPath] { + nameLen += 2 // indent for clipboard entries + } if nameLen > maxLen { maxLen = nameLen } @@ -321,16 +331,49 @@ func (fm *FileManager) render() { rightW = 0 } + // Load clipboard for display + clipOp, clipPaths := loadClipboard() + clipSet := make(map[string]bool) + for _, p := range clipPaths { + clipSet[p] = true + } + // Header row header := fmt.Sprintf(" %s@%s: %s", fm.username, fm.hostname, fm.currentDir) - headerRunes := utf8.RuneCountInString(header) - if headerRunes > fm.cols { - header = truncateMiddle(header, fm.cols) - } else { - header += strings.Repeat(" ", fm.cols-headerRunes) + + // Clipboard status suffix + clipStatus := "" + if clipOp != "" && len(clipPaths) > 0 { + noun := "file" + if len(clipPaths) != 1 { + noun = "files" + } + clipStatus = fmt.Sprintf(" %s %d %s", strings.ToUpper(clipOp), len(clipPaths), noun) } + + headerRunes := utf8.RuneCountInString(header) + clipStatusRunes := utf8.RuneCountInString(clipStatus) + buf.WriteString("\033[7m") // reverse video + if headerRunes+clipStatusRunes > fm.cols { + header = truncateMiddle(header, fm.cols-clipStatusRunes) + headerRunes = fm.cols - clipStatusRunes + } buf.WriteString(header) + // Pad between header and clip status + pad := fm.cols - headerRunes - clipStatusRunes + if pad > 0 { + buf.WriteString(strings.Repeat(" ", pad)) + } + if clipStatus != "" { + if clipOp == "cut" { + buf.WriteString("\033[31m") // red + } else { + buf.WriteString("\033[32m") // green + } + buf.WriteString(clipStatus) + buf.WriteString("\033[39m") // reset fg, keep reverse + } buf.WriteString("\033[0m") // Preview content @@ -348,30 +391,47 @@ func (fm *FileManager) render() { if entry.IsDir() { name += "/" } + + entryPath := filepath.Join(fm.currentDir, entry.Name()) + inClip := clipSet[entryPath] + indent := 0 + if inClip { + indent = 2 + } + nameRunes := utf8.RuneCountInString(name) if idx == fm.cursor { buf.WriteString("\033[7m") // reverse video for selected } - if entry.IsDir() { + if inClip { + if clipOp == "cut" { + buf.WriteString("\033[31m") // red + } else { + buf.WriteString("\033[32m") // green + } + } else if entry.IsDir() { buf.WriteString("\033[34m") // blue for directories } - availW := leftW - 1 // 1 for leading space + availW := leftW - 1 - indent // 1 for leading space if nameRunes > availW { name = truncateMiddle(name, availW) nameRunes = availW } buf.WriteString(" ") + if indent > 0 { + buf.WriteString(strings.Repeat(" ", indent)) + } fm.writeHighlightedName(&buf, name, entry.IsDir(), idx == fm.cursor) // Pad to leftW - pad := availW - nameRunes - if pad > 0 { - buf.WriteString(strings.Repeat(" ", pad)) + padLeft := availW - nameRunes + if padLeft > 0 { + buf.WriteString(strings.Repeat(" ", padLeft)) } - if idx == fm.cursor { + if idx == fm.cursor || inClip { buf.WriteString("\033[0m") } } else { @@ -699,8 +759,24 @@ func (fm *FileManager) handleInput() bool { return false case 'n': fm.searchNext() - case 'N', 'p': + case 'N': fm.searchPrev() + case 'd': + fm.clipboardCut() + case 'y': + if fm.lastKey == 'y' { + fm.clipboardCopy() + fm.lastKey = 0 + return false + } + fm.lastKey = 'y' + return false + case 'p': + fm.clipboardPaste() + case 'c': + clearClipboard() + case 'x': + fm.deleteEntry() case 'm': fm.pendingMark = true return false @@ -709,7 +785,7 @@ func (fm *FileManager) handleInput() bool { return false } - if key != 'g' { + if key != 'g' && key != 'y' { fm.lastKey = 0 } @@ -1078,6 +1154,252 @@ func (fm *FileManager) openEditor() { fm.adjustScroll() } +// Clipboard actions + +func (fm *FileManager) clipboardCut() { + if len(fm.entries) == 0 || fm.cursor >= len(fm.entries) { + return + } + absPath := filepath.Join(fm.currentDir, fm.entries[fm.cursor].Name()) + saveClipboard("cut", []string{absPath}) +} + +func (fm *FileManager) clipboardCopy() { + if len(fm.entries) == 0 || fm.cursor >= len(fm.entries) { + return + } + absPath := filepath.Join(fm.currentDir, fm.entries[fm.cursor].Name()) + saveClipboard("copy", []string{absPath}) +} + +func (fm *FileManager) clipboardPaste() { + op, paths := loadClipboard() + if op == "" || len(paths) == 0 { + return + } + for _, src := range paths { + base := filepath.Base(src) + dest := filepath.Join(fm.currentDir, base) + if src == dest { + if op == "cut" { + continue + } + // Copy to same directory: go straight to versioned name + dest = versionedPath(dest) + } else if _, err := os.Lstat(dest); err == nil { + // Destination exists but is a different file + choice := fm.promptConflict(base) + if choice == 0 { + // Cancel + return + } + if choice == 2 { + // Version number + dest = versionedPath(dest) + } + // choice == 1: overwrite, use dest as-is + } + + if op == "cut" { + if err := os.Rename(src, dest); err != nil { + return + } + } else { + if err := CopyFile(src, dest); err != nil { + return + } + } + } + if op == "cut" { + clearClipboard() + } + fm.loadDirectory() + fm.clampCursor() + fm.adjustScroll() +} + +// promptConflict shows an overlay asking the user how to handle a name conflict. +// Returns 0=cancel, 1=overwrite, 2=version number. +func (fm *FileManager) promptConflict(name string) int { + lines := []string{ + fmt.Sprintf(" \"%s\" already exists ", name), + "", + " o Overwrite", + " v Rename with version number", + " Esc Cancel", + } + + // Find box width + boxW := 0 + for _, l := range lines { + runes := utf8.RuneCountInString(l) + if runes > boxW { + boxW = runes + } + } + boxW += 2 // padding + if boxW > fm.cols-4 { + boxW = fm.cols - 4 + } + + startCol := (fm.cols - boxW) / 2 + if startCol < 1 { + startCol = 1 + } + startRow := (fm.rows-len(lines))/2 + 1 + if startRow < 2 { + startRow = 2 + } + + var buf bytes.Buffer + for i, line := range lines { + row := startRow + i + if row > fm.rows { + break + } + buf.WriteString(fmt.Sprintf("\033[%d;%dH", row, startCol)) + buf.WriteString("\033[7m") + lineRunes := utf8.RuneCountInString(line) + if lineRunes > boxW { + line = truncateMiddle(line, boxW) + lineRunes = boxW + } + buf.WriteString(line) + pad := boxW - lineRunes + if pad > 0 { + buf.WriteString(strings.Repeat(" ", pad)) + } + buf.WriteString("\033[0m") + } + fm.ttyOut.Write(buf.Bytes()) + + // Read one key + keyBuf := make([]byte, 16) + n, err := os.Stdin.Read(keyBuf) + if err != nil || n == 0 { + return 0 + } + switch keyBuf[0] { + case 'o': + return 1 + case 'v': + return 2 + default: + return 0 + } +} + +// versionedPath returns a path with a version number appended, e.g. +// "file.txt" -> "file (1).txt", "dir" -> "dir (1)". +func versionedPath(dest string) string { + dir := filepath.Dir(dest) + base := filepath.Base(dest) + ext := filepath.Ext(base) + stem := strings.TrimSuffix(base, ext) + + for i := 1; ; i++ { + candidate := filepath.Join(dir, fmt.Sprintf("%s (%d)%s", stem, i, ext)) + if _, err := os.Lstat(candidate); err != nil { + return candidate + } + } +} + +// Delete to trash + +func (fm *FileManager) deleteEntry() { + if len(fm.entries) == 0 || fm.cursor >= len(fm.entries) { + return + } + entry := fm.entries[fm.cursor] + name := entry.Name() + absPath := filepath.Join(fm.currentDir, name) + + if !fm.promptDelete(entry) { + return + } + + if err := TrashFile(absPath); err != nil { + return + } + + fm.loadDirectory() + fm.clampCursor() + fm.adjustScroll() +} + +// promptDelete shows a confirmation overlay. Returns true if the user confirms. +func (fm *FileManager) promptDelete(entry os.DirEntry) bool { + name := entry.Name() + lines := []string{ + fmt.Sprintf(" Delete \"%s\"? ", name), + } + + // For non-empty directories, show entry count + if entry.IsDir() { + dirPath := filepath.Join(fm.currentDir, name) + if subEntries, err := os.ReadDir(dirPath); err == nil && len(subEntries) > 0 { + lines = append(lines, fmt.Sprintf(" (directory with %d entries) ", len(subEntries))) + } + } + + lines = append(lines, "") + lines = append(lines, " y Confirm") + lines = append(lines, " Esc Cancel") + + // Find box width + boxW := 0 + for _, l := range lines { + runes := utf8.RuneCountInString(l) + if runes > boxW { + boxW = runes + } + } + boxW += 2 + if boxW > fm.cols-4 { + boxW = fm.cols - 4 + } + + startCol := (fm.cols - boxW) / 2 + if startCol < 1 { + startCol = 1 + } + startRow := (fm.rows-len(lines))/2 + 1 + if startRow < 2 { + startRow = 2 + } + + var buf bytes.Buffer + for i, line := range lines { + row := startRow + i + if row > fm.rows { + break + } + buf.WriteString(fmt.Sprintf("\033[%d;%dH", row, startCol)) + buf.WriteString("\033[7m") + lineRunes := utf8.RuneCountInString(line) + if lineRunes > boxW { + line = truncateMiddle(line, boxW) + lineRunes = boxW + } + buf.WriteString(line) + pad := boxW - lineRunes + if pad > 0 { + buf.WriteString(strings.Repeat(" ", pad)) + } + buf.WriteString("\033[0m") + } + fm.ttyOut.Write(buf.Bytes()) + + // Read one key + keyBuf := make([]byte, 16) + n, err := os.Stdin.Read(keyBuf) + if err != nil || n == 0 { + return false + } + return keyBuf[0] == 'y' +} + // Bookmarks const bookmarksFileName = "fm_bookmarks" @@ -1112,6 +1434,61 @@ func isBookmarkChar(c byte) bool { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') } +// Clipboard + +const clipboardFileName = "fm_clipboard" + +func clipboardFilePath() (string, error) { + dir, err := GetHistoryDir() + if err != nil { + return "", err + } + return dir + clipboardFileName, nil +} + +func loadClipboard() (string, []string) { + path, err := clipboardFilePath() + if err != nil { + return "", nil + } + data, err := os.ReadFile(path) + if err != nil { + return "", nil + } + lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") + if len(lines) < 2 { + return "", nil + } + op := lines[0] + if op != "cut" && op != "copy" { + return "", nil + } + return op, lines[1:] +} + +func saveClipboard(op string, paths []string) error { + path, err := clipboardFilePath() + if err != nil { + return err + } + var sb strings.Builder + sb.WriteString(op) + sb.WriteByte('\n') + for _, p := range paths { + sb.WriteString(p) + sb.WriteByte('\n') + } + return os.WriteFile(path, []byte(sb.String()), 0644) +} + +func clearClipboard() error { + path, err := clipboardFilePath() + if err != nil { + return err + } + return os.Remove(path) +} + func saveBookmarks(bookmarks map[byte]string) error { path, err := bookmarksFilePath() if err != nil { diff --git a/mshell/Trash_darwin.go b/mshell/Trash_darwin.go new file mode 100644 index 0000000..580c629 --- /dev/null +++ b/mshell/Trash_darwin.go @@ -0,0 +1,9 @@ +package main + +import ( + "os/exec" +) + +func TrashFile(absPath string) error { + return exec.Command("trash", absPath).Run() +} diff --git a/mshell/Trash_linux.go b/mshell/Trash_linux.go new file mode 100644 index 0000000..ece2086 --- /dev/null +++ b/mshell/Trash_linux.go @@ -0,0 +1,114 @@ +package main + +import ( + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "syscall" + "time" +) + +func TrashFile(absPath string) error { + // Determine trash directory per FreeDesktop.org Trash spec + trashDir := os.Getenv("XDG_DATA_HOME") + if trashDir == "" { + home, err := os.UserHomeDir() + if err != nil { + return err + } + trashDir = filepath.Join(home, ".local", "share") + } + trashDir = filepath.Join(trashDir, "Trash") + + filesDir := filepath.Join(trashDir, "files") + infoDir := filepath.Join(trashDir, "info") + + if err := os.MkdirAll(filesDir, 0700); err != nil { + return err + } + if err := os.MkdirAll(infoDir, 0700); err != nil { + return err + } + + baseName := filepath.Base(absPath) + + // Find a unique trash name using O_CREATE|O_EXCL for atomicity + trashName := baseName + var infoFile *os.File + for i := 2; ; i++ { + infoPath := filepath.Join(infoDir, trashName+".trashinfo") + f, err := os.OpenFile(infoPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err == nil { + infoFile = f + break + } + if !errors.Is(err, os.ErrExist) { + return err + } + ext := filepath.Ext(baseName) + stem := baseName[:len(baseName)-len(ext)] + trashName = fmt.Sprintf("%s.%d%s", stem, i, ext) + } + + // Write .trashinfo content + encodedPath := url.PathEscape(absPath) + // url.PathEscape encodes '/' which we want to keep as literal + // Re-encode: PathEscape is close but we need to unescape slashes + // Actually, per the spec we need percent-encoding per RFC 2396. + // url.PathEscape does the right thing except it encodes '/'. + // Let's just use a simple approach: encode each component. + encodedPath = encodeTrashPath(absPath) + + deletionDate := time.Now().Format("2006-01-02T15:04:05") + content := fmt.Sprintf("[Trash Info]\nPath=%s\nDeletionDate=%s\n", encodedPath, deletionDate) + _, err := infoFile.WriteString(content) + infoFile.Close() + if err != nil { + os.Remove(filepath.Join(infoDir, trashName+".trashinfo")) + return err + } + + // Move the file into the trash files directory + destPath := filepath.Join(filesDir, trashName) + err = os.Rename(absPath, destPath) + if err != nil { + // Check for cross-device link error + var linkErr *os.LinkError + if errors.As(err, &linkErr) && errors.Is(linkErr.Err, syscall.EXDEV) { + // Fall back to copy + remove + if copyErr := CopyFile(absPath, destPath); copyErr != nil { + os.Remove(filepath.Join(infoDir, trashName+".trashinfo")) + return copyErr + } + if removeErr := os.RemoveAll(absPath); removeErr != nil { + return removeErr + } + return nil + } + os.Remove(filepath.Join(infoDir, trashName+".trashinfo")) + return err + } + + return nil +} + +// encodeTrashPath percent-encodes a path per the FreeDesktop trash spec. +// Slashes are preserved; other non-unreserved characters are encoded. +func encodeTrashPath(path string) string { + var buf []byte + for i := 0; i < len(path); i++ { + c := path[i] + if c == '/' || + (c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || + c == '-' || c == '_' || c == '.' || c == '~' { + buf = append(buf, c) + } else { + buf = append(buf, fmt.Sprintf("%%%02X", c)...) + } + } + return string(buf) +} diff --git a/mshell/Trash_windows.go b/mshell/Trash_windows.go new file mode 100644 index 0000000..c29448a --- /dev/null +++ b/mshell/Trash_windows.go @@ -0,0 +1,56 @@ +package main + +import ( + "fmt" + "syscall" + "unsafe" +) + +var ( + shell32 = syscall.NewLazyDLL("shell32.dll") + procSHFileOperationW = shell32.NewProc("SHFileOperationW") +) + +const ( + foDelete = 0x3 + fofAllowUndo = 0x40 + fofNoConfirmation = 0x10 + fofSilent = 0x4 + fofNoErrorUI = 0x400 +) + +// SHFILEOPSTRUCTW for 64-bit Windows +type shFileOpStructW struct { + Hwnd uintptr + WFunc uint32 + _ [4]byte // padding for 64-bit alignment + PFrom *uint16 + PTo *uint16 + FFlags uint16 + FAnyOperationsAborted int32 + _ [2]byte // padding + HNameMappings uintptr + LpszProgressTitle *uint16 +} + +func TrashFile(absPath string) error { + // Convert path to UTF-16 with double-null termination + pathUTF16, err := syscall.UTF16FromString(absPath) + if err != nil { + return err + } + // UTF16FromString already adds one null; add another for double-null + pathUTF16 = append(pathUTF16, 0) + + op := shFileOpStructW{ + WFunc: foDelete, + PFrom: &pathUTF16[0], + FFlags: fofAllowUndo | fofNoConfirmation | fofSilent | fofNoErrorUI, + } + + ret, _, _ := procSHFileOperationW.Call(uintptr(unsafe.Pointer(&op))) + if ret != 0 { + return fmt.Errorf("SHFileOperationW failed with code %d", ret) + } + return nil +}