add keybind to sort table

lots of refactoring to do so
files on disk, and files in trash interface'd so table is agnostic
model keeps track of the files now, to facilitate sorting, and updates its own rows accordingly
This commit is contained in:
Lilian Jónsdóttir 2024-07-03 15:05:17 -07:00
parent dff6c62e56
commit 6af87e4d9d
7 changed files with 448 additions and 394 deletions

175
internal/files/disk.go Normal file
View file

@ -0,0 +1,175 @@
package files
import (
"io/fs"
"os"
"path/filepath"
"strings"
"time"
"git.burning.moe/celediel/gt/internal/filter"
"github.com/charmbracelet/log"
"github.com/dustin/go-humanize"
)
type DiskFile struct {
name, path string
filesize int64
modified time.Time
isdir bool
}
func (f DiskFile) Name() string { return f.name }
func (f DiskFile) Path() string { return filepath.Join(f.path, f.name) }
func (f DiskFile) Date() time.Time { return f.modified }
func (f DiskFile) Filesize() int64 { return f.filesize }
func (f DiskFile) IsDir() bool { return f.isdir }
func NewDisk(path string) (DiskFile, error) {
info, err := os.Stat(path)
if err != nil {
return DiskFile{}, err
}
abs, err := filepath.Abs(path)
if err != nil {
log.Errorf("couldn't get absolute path for %s", path)
abs = path
}
name := filepath.Base(abs)
base_path := filepath.Dir(abs)
log.Debugf("%s (base:%s) (size:%s) (modified:%s) exists",
name, base_path, humanize.Bytes(uint64(info.Size())), info.ModTime())
return DiskFile{
name: name,
path: base_path,
filesize: info.Size(),
modified: info.ModTime(),
isdir: info.IsDir(),
}, nil
}
func FindDisk(dir string, recursive bool, f *filter.Filter) (files Files, err error) {
if dir == "." || dir == "" {
var d string
if d, err = os.Getwd(); err != nil {
return
} else {
dir = d
}
}
var recursively string
if recursive {
recursively = " recursively"
}
log.Debugf("gonna find files%s in %s matching %s", recursively, dir, f)
if recursive {
files = append(files, walk_dir(dir, f)...)
} else {
files = append(files, read_dir(dir, f)...)
}
return
}
// is_in_recursive_dir checks `path` and parent directories
// of `path` up to `base` for a hidden parent
func is_in_recursive_dir(base, path string) bool {
me := path
for {
me = filepath.Clean(me)
if me == base {
break
}
if strings.HasPrefix(filepath.Base(me), ".") {
return true
}
me += string(os.PathSeparator) + ".."
}
return false
}
func walk_dir(dir string, f *filter.Filter) (files Files) {
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if dir == path {
return nil
}
if is_in_recursive_dir(dir, path) && !f.IgnoreHidden() {
return nil
}
p, e := filepath.Abs(path)
if e != nil {
return err
}
name := d.Name()
info, _ := d.Info()
if f.Match(name, info.ModTime(), info.Size(), info.IsDir()) {
log.Debugf("found matching file: %s %s", name, info.ModTime())
i, err := os.Stat(p)
if err != nil {
log.Debugf("error in file stat: %s", err)
return nil
}
files = append(files, DiskFile{
path: filepath.Dir(p),
name: name,
filesize: i.Size(),
modified: i.ModTime(),
isdir: i.IsDir(),
})
} else {
log.Debugf("ignoring file %s (%s)", name, info.ModTime())
}
return nil
})
if err != nil {
log.Errorf("error walking directory %s: %s", dir, err)
return Files{}
}
return
}
func read_dir(dir string, f *filter.Filter) (files Files) {
fs, err := os.ReadDir(dir)
if err != nil {
return Files{}
}
for _, file := range fs {
name := file.Name()
if name == dir {
continue
}
info, err := file.Info()
if err != nil {
return Files{}
}
path := filepath.Dir(filepath.Join(dir, name))
if f.Match(name, info.ModTime(), info.Size(), info.IsDir()) {
log.Debugf("found matching file: %s %s", name, info.ModTime())
files = append(files, DiskFile{
name: name,
path: path,
modified: info.ModTime(),
filesize: info.Size(),
isdir: info.IsDir(),
})
} else {
log.Debugf("ignoring file %s (%s)", name, info.ModTime())
}
}
return
}

View file

@ -1,186 +1,22 @@
// Package files finds and displays files on disk
package files
import (
"io/fs"
"os"
"path/filepath"
"strings"
"time"
import "time"
"git.burning.moe/celediel/gt/internal/filter"
"github.com/charmbracelet/log"
"github.com/dustin/go-humanize"
)
type File struct {
name, path string
filesize int64
modified time.Time
isdir bool
type File interface {
Name() string
Path() string
Date() time.Time
Filesize() int64
IsDir() bool
}
type Files []File
func (f File) Name() string { return f.name }
func (f File) Path() string { return f.path }
func (f File) Filename() string { return filepath.Join(f.path, f.name) }
func (f File) Modified() time.Time { return f.modified }
func (f File) Filesize() int64 { return f.filesize }
func (f File) IsDir() bool { return f.isdir }
func New(path string) (File, error) {
info, err := os.Stat(path)
if err != nil {
return File{}, err
}
abs, err := filepath.Abs(path)
if err != nil {
log.Errorf("couldn't get absolute path for %s", path)
abs = path
}
name := filepath.Base(abs)
base_path := filepath.Dir(abs)
log.Debugf("%s (base:%s) (size:%s) (modified:%s) exists",
name, base_path, humanize.Bytes(uint64(info.Size())), info.ModTime())
return File{
name: name,
path: base_path,
filesize: info.Size(),
modified: info.ModTime(),
isdir: info.IsDir(),
}, nil
}
func Find(dir string, recursive bool, f *filter.Filter) (files Files, err error) {
if dir == "." || dir == "" {
var d string
if d, err = os.Getwd(); err != nil {
return
} else {
dir = d
}
}
var recursively string
if recursive {
recursively = " recursively"
}
log.Debugf("gonna find files%s in %s matching %s", recursively, dir, f)
if recursive {
files = append(files, walk_dir(dir, f)...)
} else {
files = append(files, read_dir(dir, f)...)
}
return
}
// is_in_recursive_dir checks `path` and parent directories
// of `path` up to `base` for a hidden parent
func is_in_recursive_dir(base, path string) bool {
me := path
for {
me = filepath.Clean(me)
if me == base {
break
}
if strings.HasPrefix(filepath.Base(me), ".") {
return true
}
me += string(os.PathSeparator) + ".."
}
return false
}
func walk_dir(dir string, f *filter.Filter) (files Files) {
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if dir == path {
return nil
}
if is_in_recursive_dir(dir, path) && !f.IgnoreHidden() {
return nil
}
p, e := filepath.Abs(path)
if e != nil {
return err
}
name := d.Name()
info, _ := d.Info()
if f.Match(name, info.ModTime(), info.Size(), info.IsDir()) {
log.Debugf("found matching file: %s %s", name, info.ModTime())
i, err := os.Stat(p)
if err != nil {
log.Debugf("error in file stat: %s", err)
return nil
}
files = append(files, File{
path: filepath.Dir(p),
name: name,
filesize: i.Size(),
modified: i.ModTime(),
isdir: i.IsDir(),
})
} else {
log.Debugf("ignoring file %s (%s)", name, info.ModTime())
}
return nil
})
if err != nil {
log.Errorf("error walking directory %s: %s", dir, err)
return []File{}
}
return
}
func read_dir(dir string, f *filter.Filter) (files Files) {
fs, err := os.ReadDir(dir)
if err != nil {
return []File{}
}
for _, file := range fs {
name := file.Name()
if name == dir {
continue
}
info, err := file.Info()
if err != nil {
return []File{}
}
path := filepath.Dir(filepath.Join(dir, name))
if f.Match(name, info.ModTime(), info.Size(), info.IsDir()) {
log.Debugf("found matching file: %s %s", name, info.ModTime())
files = append(files, File{
name: name,
path: path,
modified: info.ModTime(),
filesize: info.Size(),
isdir: info.IsDir(),
})
} else {
log.Debugf("ignoring file %s (%s)", name, info.ModTime())
}
}
return
}
func SortByModified(a, b File) int {
if a.modified.After(b.modified) {
if a.Date().After(b.Date()) {
return 1
} else if a.modified.Before(b.modified) {
} else if a.Date().Before(b.Date()) {
return -1
} else {
return 0
@ -188,9 +24,9 @@ func SortByModified(a, b File) int {
}
func SortByModifiedReverse(a, b File) int {
if a.modified.Before(b.modified) {
if a.Date().Before(b.Date()) {
return 1
} else if a.modified.After(b.modified) {
} else if a.Date().After(b.Date()) {
return -1
} else {
return 0
@ -198,9 +34,9 @@ func SortByModifiedReverse(a, b File) int {
}
func SortBySize(a, b File) int {
if a.filesize > b.filesize {
if a.Filesize() > b.Filesize() {
return 1
} else if a.filesize < b.filesize {
} else if a.Filesize() < b.Filesize() {
return -1
} else {
return 0
@ -208,9 +44,49 @@ func SortBySize(a, b File) int {
}
func SortBySizeReverse(a, b File) int {
if a.filesize < b.filesize {
if a.Filesize() < b.Filesize() {
return 1
} else if a.filesize > b.filesize {
} else if a.Filesize() > b.Filesize() {
return -1
} else {
return 0
}
}
func SortByName(a, b File) int {
if a.Name() > b.Name() {
return 1
} else if a.Name() < b.Name() {
return -1
} else {
return 0
}
}
func SortByNameReverse(a, b File) int {
if a.Name() < b.Name() {
return 1
} else if a.Name() > b.Name() {
return -1
} else {
return 0
}
}
func SortByPath(a, b File) int {
if a.Path() > b.Path() {
return 1
} else if a.Path() < b.Path() {
return -1
} else {
return 0
}
}
func SortByPathReverse(a, b File) int {
if a.Path() < b.Path() {
return 1
} else if a.Path() > b.Path() {
return -1
} else {
return 0

262
internal/files/trash.go Normal file
View file

@ -0,0 +1,262 @@
package files
import (
"fmt"
"io/fs"
"math/rand"
"os"
"path/filepath"
"strings"
"time"
"git.burning.moe/celediel/gt/internal/dirs"
"git.burning.moe/celediel/gt/internal/filter"
"git.burning.moe/celediel/gt/internal/prompt"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/log"
"github.com/dustin/go-humanize"
"gitlab.com/tymonx/go-formatter/formatter"
"gopkg.in/ini.v1"
)
const (
random_str_length int = 8
trash_info_ext string = ".trashinfo"
trash_info_sec string = "Trash Info"
trash_info_path string = "Path"
trash_info_date string = "DeletionDate"
trash_info_date_fmt string = "2006-01-02T15:04:05"
trash_info_template string = `[Trash Info]
Path={path}
DeletionDate={date}
`
)
type TrashInfo struct {
name, ogpath string
path, trashinfo string
isdir bool
trashed time.Time
filesize int64
}
func (t TrashInfo) Name() string { return t.name }
func (t TrashInfo) TrashPath() string { return t.path }
func (t TrashInfo) Path() string { return t.ogpath }
func (t TrashInfo) TrashInfo() string { return t.trashinfo }
func (t TrashInfo) Date() time.Time { return t.trashed }
func (t TrashInfo) Filesize() int64 { return t.filesize }
func (t TrashInfo) IsDir() bool { return t.isdir }
func FindTrash(trashdir, ogdir string, f *filter.Filter) (files Files, outerr error) {
outerr = filepath.WalkDir(trashdir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
log.Debugf("what happened?? what is %s?", err)
return err
}
// ignore self, directories, and non trashinfo files
if path == trashdir || d.IsDir() || filepath.Ext(path) != trash_info_ext {
return nil
}
// trashinfo is just an ini file, so
c, err := ini.Load(path)
if err != nil {
return err
}
if s := c.Section(trash_info_sec); s != nil {
basepath := s.Key(trash_info_path).String()
filename := filepath.Base(basepath)
trashedpath := strings.Replace(strings.Replace(path, "info", "files", 1), trash_info_ext, "", 1)
info, err := os.Stat(trashedpath)
if err != nil {
log.Errorf("error reading %s: %s", trashedpath, err)
}
s := s.Key(trash_info_date).Value()
date, err := time.ParseInLocation(trash_info_date_fmt, s, time.Local)
if err != nil {
return err
}
if ogdir != "" && filepath.Dir(basepath) != ogdir {
return nil
}
if f.Match(filename, date, info.Size(), info.IsDir()) {
log.Debugf("%s: deleted on %s", filename, date.Format(trash_info_date_fmt))
files = append(files, TrashInfo{
name: filename,
path: trashedpath,
ogpath: basepath,
trashinfo: path,
trashed: date,
isdir: info.IsDir(),
filesize: info.Size(),
})
} else {
log.Debugf("(ignored) %s: deleted on %s", filename, date.Format(trash_info_date_fmt))
}
}
return nil
})
if outerr != nil {
return Files{}, outerr
}
return
}
func Restore(files Files) (restored int, err error) {
for _, maybeFile := range files {
file, ok := maybeFile.(TrashInfo)
if !ok {
return restored, fmt.Errorf("bad file?? %s", maybeFile.Name())
}
var outpath string = dirs.UnEscape(file.ogpath)
var cancel bool
log.Infof("restoring %s back to %s\n", file.name, outpath)
if _, e := os.Stat(outpath); e == nil {
outpath, cancel = promptNewPath(outpath)
}
if cancel {
continue
}
if err = os.Rename(file.path, outpath); err != nil {
return restored, err
}
if err = os.Remove(file.trashinfo); err != nil {
return restored, err
}
restored++
}
return restored, err
}
func Remove(files Files) (removed int, err error) {
for _, maybeFile := range files {
file, ok := maybeFile.(TrashInfo)
if !ok {
return removed, fmt.Errorf("bad file?? %s", maybeFile.Name())
}
log.Infof("removing %s permanently forever!!!", file.name)
if err = os.Remove(file.path); err != nil {
if i, e := os.Stat(file.path); e == nil && i.IsDir() {
err = os.RemoveAll(file.path)
if err != nil {
return removed, err
}
} else {
return removed, err
}
}
if err = os.Remove(file.trashinfo); err != nil {
return removed, err
}
removed++
}
return removed, err
}
func TrashFile(trashDir, name string) error {
trashinfo_filename, out_path := ensureUniqueName(filepath.Base(name), trashDir)
// TODO: write across filesystems
if err := os.Rename(name, out_path); err != nil {
if strings.Contains(err.Error(), "invalid cross-device link") {
return fmt.Errorf("not trashing file '%s': On different filesystem from trash directory", name)
}
return err
}
trash_info, err := formatter.Format(trash_info_template, formatter.Named{
"path": name,
"date": time.Now().Format(trash_info_date_fmt),
})
if err != nil {
return err
}
if err := os.WriteFile(trashinfo_filename, []byte(trash_info), fs.FileMode(0600)); err != nil {
return err
}
return nil
}
func TrashFiles(trashDir string, files ...string) (trashed int, err error) {
for _, file := range files {
if err = TrashFile(trashDir, file); err != nil {
return trashed, err
}
trashed++
}
return trashed, err
}
func randomFilename(length int) string {
out := strings.Builder{}
for range length {
out.WriteByte(randomChar())
}
return out.String()
}
func randomChar() byte {
const chars string = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890"
return chars[rand.Intn(len(chars))]
}
func ensureUniqueName(filename, trashDir string) (string, string) {
var (
filedir = filepath.Join(trashDir, "files")
infodir = filepath.Join(trashDir, "info")
)
info := filepath.Join(infodir, filename+trash_info_ext)
if _, err := os.Stat(info); os.IsNotExist(err) {
// doesn't exist, so use it
path := filepath.Join(filedir, filename)
return info, path
} else {
// otherwise, try random suffixes until one works
log.Debugf("%s exists in trash, generating random name", filename)
var tries int
for {
tries++
rando := randomFilename(random_str_length)
new_name := filepath.Join(infodir, filename+rando+trash_info_ext)
if _, err := os.Stat(new_name); os.IsNotExist(err) {
path := filepath.Join(filedir, filename+rando)
log.Debugf("settled on random name %s%s on the %s try", filename, rando, humanize.Ordinal(tries))
return new_name, path
}
}
}
}
func promptNewPath(path string) (string, bool) {
for {
answer := prompt.AskRune(fmt.Sprintf("file %s exists, overwrite, rename, or cancel?", path), "o/r/c")
switch answer {
case 'o', 'O':
return path, false
case 'r', 'R':
if err := huh.NewInput().
Title("input a new filename").
Value(&path).
Run(); err != nil {
return path, false
}
if _, e := os.Stat(path); e != nil {
return path, false
}
default:
return path, true
}
}
}