Add Bubble Tea TUI with interactive treemap
Cell canvas renderer with frame cache and a perceptual 256-colour quantizer; responsive geometry (large/medium/small/too-small); squarified treemap with nested previews, LOD grouping, category colouring and adaptive labels; spatial arrow navigation with stable NodeID selection; zoom and breadcrumbs; modal system (info, help, confirm, save); list, file-type, top-N, scan-info, duplicate and comparison views; live search/filter; mouse support; move-to-trash.
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// startDetached runs a helper program with an argument vector (never a
|
||||
// shell), detached from the terminal so it cannot disturb the TUI.
|
||||
func startDetached(name string, args ...string) error {
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd.Stdin, cmd.Stdout, cmd.Stderr = nil, nil, nil
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
go cmd.Wait()
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkAbs guards helper invocations: an absolute path can never be parsed
|
||||
// as an option by the helper program.
|
||||
func checkAbs(path string) error {
|
||||
if !filepath.IsAbs(path) {
|
||||
return errors.New("refusing relative path")
|
||||
}
|
||||
if _, err := os.Lstat(path); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// TrashName is the user-facing name of the trash.
|
||||
const TrashName = "Trash"
|
||||
|
||||
// Reveal selects the item in Finder.
|
||||
func Reveal(path string) error {
|
||||
if err := checkAbs(path); err != nil {
|
||||
return err
|
||||
}
|
||||
return startDetached("open", "-R", path)
|
||||
}
|
||||
|
||||
// Trash moves path into ~/.Trash (same volume) or the volume's
|
||||
// .Trashes/<uid> directory. It never copies and never deletes.
|
||||
func Trash(path string) error {
|
||||
if err := checkAbs(path); err != nil {
|
||||
return err
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dirs := []string{filepath.Join(home, ".Trash")}
|
||||
if vol := volumeOf(path); vol != "" {
|
||||
dirs = append(dirs, filepath.Join(vol, ".Trashes", fmt.Sprint(os.Getuid())))
|
||||
}
|
||||
base := filepath.Base(path)
|
||||
for _, dir := range dirs {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
continue
|
||||
}
|
||||
for i := 1; i < 10000; i++ {
|
||||
name := base
|
||||
if i > 1 {
|
||||
name = fmt.Sprintf("%s %d", base, i)
|
||||
}
|
||||
dst := filepath.Join(dir, name)
|
||||
if _, err := os.Lstat(dst); err == nil {
|
||||
continue
|
||||
}
|
||||
err := os.Rename(path, dst)
|
||||
if errors.Is(err, syscall.EXDEV) {
|
||||
break // try the next trash location
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
return errors.New("trash: item is on a volume without a usable trash")
|
||||
}
|
||||
|
||||
func volumeOf(path string) string {
|
||||
const prefix = "/Volumes/"
|
||||
if len(path) > len(prefix) && path[:len(prefix)] == prefix {
|
||||
rest := path[len(prefix):]
|
||||
for i := 0; i < len(rest); i++ {
|
||||
if rest[i] == '/' {
|
||||
return prefix + rest[:i]
|
||||
}
|
||||
}
|
||||
return path
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TrashName is the user-facing name of the trash.
|
||||
const TrashName = "trash"
|
||||
|
||||
// Reveal opens the item's directory in the desktop file manager.
|
||||
func Reveal(path string) error {
|
||||
if err := checkAbs(path); err != nil {
|
||||
return err
|
||||
}
|
||||
fi, err := os.Lstat(path)
|
||||
if err == nil && !fi.IsDir() {
|
||||
path = filepath.Dir(path)
|
||||
}
|
||||
return startDetached("xdg-open", path)
|
||||
}
|
||||
|
||||
// Trash moves path to the freedesktop.org trash: the home trash when on the
|
||||
// same filesystem, otherwise $topdir/.Trash-$uid on the item's filesystem.
|
||||
// It never copies across filesystems and never deletes.
|
||||
func Trash(path string) error {
|
||||
if err := checkAbs(path); err != nil {
|
||||
return err
|
||||
}
|
||||
st, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dev := st.Sys().(*syscall.Stat_t).Dev
|
||||
dataHome := os.Getenv("XDG_DATA_HOME")
|
||||
if dataHome == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dataHome = filepath.Join(home, ".local", "share")
|
||||
}
|
||||
home := filepath.Join(dataHome, "Trash")
|
||||
var trash, infoPath string
|
||||
if hs, err := os.Stat(dataHome); err == nil && hs.Sys().(*syscall.Stat_t).Dev == dev {
|
||||
trash, infoPath = home, path
|
||||
} else {
|
||||
top := mountTop(path, dev)
|
||||
trash = filepath.Join(top, ".Trash-"+strconv.Itoa(os.Getuid()))
|
||||
rel, err := filepath.Rel(top, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
infoPath = rel
|
||||
}
|
||||
files, info := filepath.Join(trash, "files"), filepath.Join(trash, "info")
|
||||
if err := os.MkdirAll(files, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(info, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
base := filepath.Base(path)
|
||||
for i := 1; i < 10000; i++ {
|
||||
name := base
|
||||
if i > 1 {
|
||||
name = fmt.Sprintf("%s.%d", base, i)
|
||||
}
|
||||
ip := filepath.Join(info, name+".trashinfo")
|
||||
f, err := os.OpenFile(ip, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u := (&url.URL{Path: infoPath}).EscapedPath()
|
||||
_, werr := fmt.Fprintf(f, "[Trash Info]\nPath=%s\nDeletionDate=%s\n", u, time.Now().Format("2006-01-02T15:04:05"))
|
||||
cerr := f.Close()
|
||||
if werr != nil || cerr != nil {
|
||||
os.Remove(ip)
|
||||
return errors.Join(werr, cerr)
|
||||
}
|
||||
if err := os.Rename(path, filepath.Join(files, name)); err != nil {
|
||||
os.Remove(ip)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return errors.New("trash: too many items with the same name")
|
||||
}
|
||||
|
||||
// mountTop returns the top directory of the filesystem containing path.
|
||||
func mountTop(path string, dev uint64) string {
|
||||
cur := filepath.Dir(path)
|
||||
for {
|
||||
parent := filepath.Dir(cur)
|
||||
if parent == cur {
|
||||
return cur
|
||||
}
|
||||
st, err := os.Stat(parent)
|
||||
if err != nil || st.Sys().(*syscall.Stat_t).Dev != dev {
|
||||
return cur
|
||||
}
|
||||
cur = parent
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//go:build !linux && !darwin && !windows
|
||||
|
||||
package platform
|
||||
|
||||
import "errors"
|
||||
|
||||
// TrashName is the user-facing name of the trash.
|
||||
const TrashName = "trash"
|
||||
|
||||
// Reveal is unsupported on this platform.
|
||||
func Reveal(string) error { return errors.New("not supported on this platform") }
|
||||
|
||||
// Trash is unsupported on this platform.
|
||||
func Trash(string) error { return errors.New("trash not supported on this platform") }
|
||||
@@ -0,0 +1,71 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// TrashName is the user-facing name of the trash.
|
||||
const TrashName = "Recycle Bin"
|
||||
|
||||
// Reveal selects the item in Explorer.
|
||||
func Reveal(path string) error {
|
||||
if err := checkAbs(path); err != nil {
|
||||
return err
|
||||
}
|
||||
return startDetached("explorer.exe", "/select,", path)
|
||||
}
|
||||
|
||||
var (
|
||||
shell32 = syscall.NewLazyDLL("shell32.dll")
|
||||
procSHFileOpertn = shell32.NewProc("SHFileOperationW")
|
||||
)
|
||||
|
||||
// shFileOpStruct mirrors SHFILEOPSTRUCTW (64-bit natural alignment).
|
||||
type shFileOpStruct struct {
|
||||
hwnd uintptr
|
||||
wFunc uint32
|
||||
pFrom *uint16
|
||||
pTo *uint16
|
||||
fFlags uint16
|
||||
fAnyOperationsAborted int32
|
||||
hNameMappings uintptr
|
||||
lpszProgressTitle *uint16
|
||||
}
|
||||
|
||||
const (
|
||||
foDelete = 0x3
|
||||
fofSilent = 0x4
|
||||
fofNoConfirmation = 0x10
|
||||
fofAllowUndo = 0x40
|
||||
fofNoErrorUI = 0x400
|
||||
fofNoConfirmMkdir = 0x200
|
||||
)
|
||||
|
||||
// Trash moves path to the Recycle Bin via SHFileOperationW with
|
||||
// FOF_ALLOWUNDO. It never deletes permanently.
|
||||
func Trash(path string) error {
|
||||
if err := checkAbs(path); err != nil {
|
||||
return err
|
||||
}
|
||||
if unsafe.Sizeof(uintptr(0)) != 8 {
|
||||
return errors.New("recycle bin support requires a 64-bit build")
|
||||
}
|
||||
u, err := syscall.UTF16FromString(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u = append(u, 0) // double NUL terminated list
|
||||
op := shFileOpStruct{wFunc: foDelete, pFrom: &u[0],
|
||||
fFlags: fofAllowUndo | fofNoConfirmation | fofSilent | fofNoErrorUI | fofNoConfirmMkdir}
|
||||
r, _, _ := procSHFileOpertn.Call(uintptr(unsafe.Pointer(&op)))
|
||||
if r != 0 {
|
||||
return errors.New("SHFileOperation failed: code " + strconv.Itoa(int(r)))
|
||||
}
|
||||
if op.fAnyOperationsAborted != 0 {
|
||||
return errors.New("operation aborted")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user