Initial vcom TUI prototype
This commit is contained in:
commit
059f925e00
16 changed files with 3227 additions and 0 deletions
95
internal/fs/entry.go
Normal file
95
internal/fs/entry.go
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
package vfs
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
configExtensions = map[string]struct{}{
|
||||
"toml": {}, "yaml": {}, "yml": {}, "json": {}, "jsonc": {}, "ini": {}, "conf": {},
|
||||
"config": {}, "env": {}, "properties": {}, "xml": {}, "mod": {}, "sum": {}, "lock": {},
|
||||
}
|
||||
textExtensions = map[string]struct{}{
|
||||
"txt": {}, "md": {}, "rst": {}, "go": {}, "rs": {}, "c": {}, "h": {}, "cpp": {}, "hpp": {},
|
||||
"py": {}, "js": {}, "ts": {}, "tsx": {}, "jsx": {}, "java": {}, "kt": {}, "swift": {},
|
||||
"html": {}, "css": {}, "scss": {}, "sh": {}, "bash": {}, "zsh": {}, "fish": {}, "sql": {},
|
||||
"log": {}, "csv": {},
|
||||
}
|
||||
imageExtensions = map[string]struct{}{
|
||||
"png": {}, "jpg": {}, "jpeg": {}, "gif": {}, "webp": {}, "bmp": {}, "svg": {}, "ico": {},
|
||||
}
|
||||
archiveExtensions = map[string]struct{}{
|
||||
"zip": {}, "tar": {}, "gz": {}, "tgz": {}, "xz": {}, "bz2": {}, "7z": {}, "rar": {},
|
||||
}
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
Name string
|
||||
Path string
|
||||
Extension string
|
||||
Mode fs.FileMode
|
||||
Size int64
|
||||
ModifiedAt time.Time
|
||||
CreatedAt time.Time
|
||||
CreatedKnown bool
|
||||
IsDir bool
|
||||
IsParent bool
|
||||
IsHidden bool
|
||||
DirSizeKnown bool
|
||||
}
|
||||
|
||||
func (e Entry) DisplayName() string {
|
||||
if e.IsParent {
|
||||
return ".."
|
||||
}
|
||||
if e.IsDir {
|
||||
return e.Name + "/"
|
||||
}
|
||||
return e.Name
|
||||
}
|
||||
|
||||
func (e Entry) IsFile() bool {
|
||||
return !e.IsDir && !e.IsParent
|
||||
}
|
||||
|
||||
func (e Entry) MatchKey() string {
|
||||
return strings.ToLower(e.Name)
|
||||
}
|
||||
|
||||
func (e Entry) IsExecutable() bool {
|
||||
return !e.IsDir && !e.IsParent && e.Mode&0o111 != 0
|
||||
}
|
||||
|
||||
func (e Entry) Category() string {
|
||||
switch {
|
||||
case e.IsParent:
|
||||
return "parent"
|
||||
case e.IsDir:
|
||||
return "directory"
|
||||
case e.IsExecutable():
|
||||
return "executable"
|
||||
case hasExt(configExtensions, e.Extension):
|
||||
return "config"
|
||||
case hasExt(imageExtensions, e.Extension):
|
||||
return "image"
|
||||
case hasExt(textExtensions, e.Extension):
|
||||
return "text"
|
||||
case hasExt(archiveExtensions, e.Extension):
|
||||
return "archive"
|
||||
default:
|
||||
return "binary"
|
||||
}
|
||||
}
|
||||
|
||||
func ext(name string) string {
|
||||
value := strings.TrimPrefix(filepath.Ext(name), ".")
|
||||
return strings.ToLower(value)
|
||||
}
|
||||
|
||||
func hasExt(set map[string]struct{}, ext string) bool {
|
||||
_, ok := set[strings.ToLower(ext)]
|
||||
return ok
|
||||
}
|
||||
183
internal/fs/ops.go
Normal file
183
internal/fs/ops.go
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
package vfs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func CopyPath(srcPath string, dstDir string, overwrite bool) (string, error) {
|
||||
srcInfo, err := os.Lstat(srcPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("stat %s: %w", srcPath, err)
|
||||
}
|
||||
|
||||
targetPath := filepath.Join(dstDir, filepath.Base(srcPath))
|
||||
if same, err := samePath(srcPath, targetPath); err != nil {
|
||||
return "", err
|
||||
} else if same {
|
||||
return "", fmt.Errorf("source and target are the same: %s", targetPath)
|
||||
}
|
||||
|
||||
if exists, err := PathExists(targetPath); err != nil {
|
||||
return "", err
|
||||
} else if exists {
|
||||
if !overwrite {
|
||||
return "", ErrOverwrite(targetPath)
|
||||
}
|
||||
if err := os.RemoveAll(targetPath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
if srcInfo.Mode()&os.ModeSymlink != 0 {
|
||||
target, err := os.Readlink(srcPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return targetPath, os.Symlink(target, targetPath)
|
||||
}
|
||||
|
||||
if srcInfo.IsDir() {
|
||||
return targetPath, copyDir(srcPath, targetPath)
|
||||
}
|
||||
|
||||
return targetPath, copyFile(srcPath, targetPath, srcInfo.Mode())
|
||||
}
|
||||
|
||||
func MovePath(srcPath string, dstDir string, overwrite bool) (string, error) {
|
||||
targetPath := filepath.Join(dstDir, filepath.Base(srcPath))
|
||||
if same, err := samePath(srcPath, targetPath); err != nil {
|
||||
return "", err
|
||||
} else if same {
|
||||
return "", fmt.Errorf("source and target are the same: %s", targetPath)
|
||||
}
|
||||
|
||||
if exists, err := PathExists(targetPath); err != nil {
|
||||
return "", err
|
||||
} else if exists {
|
||||
if !overwrite {
|
||||
return "", ErrOverwrite(targetPath)
|
||||
}
|
||||
if err := os.RemoveAll(targetPath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.Rename(srcPath, targetPath); err == nil {
|
||||
return targetPath, nil
|
||||
} else if !errors.Is(err, syscall.EXDEV) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
targetPath, err := CopyPath(srcPath, dstDir, overwrite)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := DeletePath(srcPath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return targetPath, nil
|
||||
}
|
||||
|
||||
func PathExists(path string) (bool, error) {
|
||||
if _, err := os.Lstat(path); err == nil {
|
||||
return true, nil
|
||||
} else if errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
} else {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
func DeletePath(path string) error {
|
||||
return os.RemoveAll(path)
|
||||
}
|
||||
|
||||
func MakeDir(parent string, name string) (string, error) {
|
||||
target := filepath.Join(parent, name)
|
||||
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func copyDir(srcDir string, dstDir string) error {
|
||||
info, err := os.Lstat(srcDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(dstDir, info.Mode().Perm()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(srcDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
srcPath := filepath.Join(srcDir, entry.Name())
|
||||
dstPath := filepath.Join(dstDir, entry.Name())
|
||||
|
||||
info, err := os.Lstat(srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch {
|
||||
case info.Mode()&os.ModeSymlink != 0:
|
||||
target, err := os.Readlink(srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Symlink(target, dstPath); err != nil {
|
||||
return err
|
||||
}
|
||||
case info.IsDir():
|
||||
if err := copyDir(srcPath, dstPath); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
if err := copyFile(srcPath, dstPath, info.Mode()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyFile(srcPath string, dstPath string, mode os.FileMode) error {
|
||||
srcFile, err := os.Open(srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
dstFile, err := os.OpenFile(dstPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode.Perm())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
if _, err := io.Copy(dstFile, srcFile); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func samePath(left string, right string) (bool, error) {
|
||||
leftAbs, err := filepath.Abs(left)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
rightAbs, err := filepath.Abs(right)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return leftAbs == rightAbs, nil
|
||||
}
|
||||
204
internal/fs/preview.go
Normal file
204
internal/fs/preview.go
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
package vfs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type PreviewKind string
|
||||
|
||||
const (
|
||||
PreviewKindEmpty PreviewKind = "empty"
|
||||
PreviewKindDirectory PreviewKind = "directory"
|
||||
PreviewKindText PreviewKind = "text"
|
||||
PreviewKindImage PreviewKind = "image"
|
||||
PreviewKindBinary PreviewKind = "binary"
|
||||
PreviewKindError PreviewKind = "error"
|
||||
)
|
||||
|
||||
type Metadata struct {
|
||||
Path string
|
||||
Kind string
|
||||
Size int64
|
||||
SizeKnown bool
|
||||
ModifiedAt string
|
||||
CreatedAt string
|
||||
Permissions string
|
||||
ImageFormat string
|
||||
ImageSize string
|
||||
Extension string
|
||||
}
|
||||
|
||||
type Preview struct {
|
||||
Kind PreviewKind
|
||||
Title string
|
||||
Body string
|
||||
Metadata Metadata
|
||||
}
|
||||
|
||||
type PreviewOptions struct {
|
||||
ShowHidden bool
|
||||
DirsFirst bool
|
||||
SortBy string
|
||||
SortReverse bool
|
||||
MaxPreviewBytes int64
|
||||
DirectoryPreviewLimit int
|
||||
HumanReadableSize bool
|
||||
}
|
||||
|
||||
func BuildPreview(entry Entry, options PreviewOptions) Preview {
|
||||
preview := Preview{
|
||||
Kind: PreviewKindEmpty,
|
||||
Title: entry.DisplayName(),
|
||||
Metadata: Metadata{
|
||||
Path: entry.Path,
|
||||
Kind: kindLabel(entry),
|
||||
Permissions: Permissions(entry.Mode),
|
||||
ModifiedAt: ShortTime(entry.ModifiedAt),
|
||||
CreatedAt: "n/a",
|
||||
Extension: entry.Extension,
|
||||
},
|
||||
}
|
||||
|
||||
if entry.CreatedKnown {
|
||||
preview.Metadata.CreatedAt = ShortTime(entry.CreatedAt)
|
||||
}
|
||||
|
||||
if entry.IsDir {
|
||||
preview.Kind = PreviewKindDirectory
|
||||
preview.Metadata.Size = entry.Size
|
||||
preview.Metadata.SizeKnown = entry.DirSizeKnown
|
||||
preview.Body = buildDirectoryPreview(entry.Path, options)
|
||||
return preview
|
||||
}
|
||||
|
||||
preview.Metadata.Size = entry.Size
|
||||
preview.Metadata.SizeKnown = true
|
||||
|
||||
file, err := os.Open(entry.Path)
|
||||
if err != nil {
|
||||
preview.Kind = PreviewKindError
|
||||
preview.Body = fmt.Sprintf("Could not open file:\n\n%s", err)
|
||||
return preview
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
buffer := new(bytes.Buffer)
|
||||
if _, err := io.CopyN(buffer, file, options.MaxPreviewBytes); err != nil && err != io.EOF {
|
||||
preview.Kind = PreviewKindError
|
||||
preview.Body = fmt.Sprintf("Could not read preview:\n\n%s", err)
|
||||
return preview
|
||||
}
|
||||
|
||||
data := buffer.Bytes()
|
||||
if format, dimensions, ok := detectImage(data); ok {
|
||||
preview.Kind = PreviewKindImage
|
||||
preview.Metadata.ImageFormat = format
|
||||
preview.Metadata.ImageSize = dimensions
|
||||
preview.Body = fmt.Sprintf(
|
||||
"Image preview is metadata-only for now.\n\nFormat: %s\nDimensions: %s\nPath: %s",
|
||||
format,
|
||||
dimensions,
|
||||
entry.Path,
|
||||
)
|
||||
return preview
|
||||
}
|
||||
|
||||
if IsBinarySample(data) {
|
||||
preview.Kind = PreviewKindBinary
|
||||
preview.Body = "Binary file detected.\n\nSafe inline preview is disabled for this file type."
|
||||
return preview
|
||||
}
|
||||
|
||||
preview.Kind = PreviewKindText
|
||||
preview.Body = strings.ReplaceAll(string(data), "\t", " ")
|
||||
return preview
|
||||
}
|
||||
|
||||
func buildDirectoryPreview(path string, options PreviewOptions) string {
|
||||
entries, err := ListDir(path, ListOptions{
|
||||
ShowHidden: options.ShowHidden,
|
||||
DirsFirst: options.DirsFirst,
|
||||
SortBy: options.SortBy,
|
||||
SortReverse: options.SortReverse,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Sprintf("Could not list directory:\n\n%s", err)
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return "Directory is empty."
|
||||
}
|
||||
|
||||
var lines []string
|
||||
for _, entry := range entries {
|
||||
if entry.IsParent {
|
||||
continue
|
||||
}
|
||||
icon := previewIcon(entry)
|
||||
|
||||
size := ""
|
||||
if !entry.IsDir {
|
||||
if options.HumanReadableSize {
|
||||
size = HumanSize(entry.Size)
|
||||
} else {
|
||||
size = fmt.Sprintf("%d", entry.Size)
|
||||
}
|
||||
}
|
||||
|
||||
lines = append(lines, fmt.Sprintf("%s %-36s %12s %s", icon, entry.DisplayName(), size, ShortTime(entry.ModifiedAt)))
|
||||
if len(lines) >= options.DirectoryPreviewLimit {
|
||||
lines = append(lines, "…")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func previewIcon(entry Entry) string {
|
||||
switch entry.Category() {
|
||||
case "directory":
|
||||
return ""
|
||||
case "config":
|
||||
return ""
|
||||
case "text":
|
||||
return ""
|
||||
case "image":
|
||||
return ""
|
||||
case "executable":
|
||||
return ""
|
||||
case "archive":
|
||||
return ""
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func detectImage(data []byte) (string, string, bool) {
|
||||
cfg, format, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
return format, fmt.Sprintf("%dx%d", cfg.Width, cfg.Height), true
|
||||
}
|
||||
|
||||
func kindLabel(entry Entry) string {
|
||||
switch {
|
||||
case entry.IsParent:
|
||||
return "parent"
|
||||
case entry.IsDir:
|
||||
return "directory"
|
||||
case entry.Extension != "":
|
||||
return "file"
|
||||
default:
|
||||
return strings.TrimPrefix(filepath.Ext(entry.Name), ".")
|
||||
}
|
||||
}
|
||||
268
internal/fs/scan.go
Normal file
268
internal/fs/scan.go
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
package vfs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
type ListOptions struct {
|
||||
ShowHidden bool
|
||||
DirsFirst bool
|
||||
SortBy string
|
||||
SortReverse bool
|
||||
}
|
||||
|
||||
func ListDir(path string, options ListOptions) ([]Entry, error) {
|
||||
resolvedPath := path
|
||||
if resolvedPath == "" {
|
||||
resolvedPath = "."
|
||||
}
|
||||
|
||||
dirEntries, err := os.ReadDir(resolvedPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read dir %s: %w", resolvedPath, err)
|
||||
}
|
||||
|
||||
entries := make([]Entry, 0, len(dirEntries)+1)
|
||||
if parent := filepath.Dir(resolvedPath); parent != resolvedPath {
|
||||
entries = append(entries, Entry{
|
||||
Name: "..",
|
||||
Path: parent,
|
||||
IsDir: true,
|
||||
IsParent: true,
|
||||
})
|
||||
}
|
||||
|
||||
for _, dirEntry := range dirEntries {
|
||||
name := dirEntry.Name()
|
||||
hidden := strings.HasPrefix(name, ".")
|
||||
if hidden && !options.ShowHidden {
|
||||
continue
|
||||
}
|
||||
|
||||
fullPath := filepath.Join(resolvedPath, name)
|
||||
info, err := dirEntry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
entry := Entry{
|
||||
Name: name,
|
||||
Path: fullPath,
|
||||
Extension: ext(name),
|
||||
Mode: info.Mode(),
|
||||
Size: info.Size(),
|
||||
ModifiedAt: info.ModTime(),
|
||||
IsDir: info.IsDir(),
|
||||
IsHidden: hidden,
|
||||
}
|
||||
|
||||
if createdAt, ok := statBirthTime(fullPath); ok {
|
||||
entry.CreatedAt = createdAt
|
||||
entry.CreatedKnown = true
|
||||
}
|
||||
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
|
||||
sort.SliceStable(entries, func(i, j int) bool {
|
||||
left, right := entries[i], entries[j]
|
||||
|
||||
if left.IsParent != right.IsParent {
|
||||
return left.IsParent
|
||||
}
|
||||
if options.DirsFirst && left.IsDir != right.IsDir {
|
||||
return left.IsDir
|
||||
}
|
||||
comparison := compareEntries(left, right, options.SortBy)
|
||||
if options.SortReverse {
|
||||
return comparison > 0
|
||||
}
|
||||
return comparison < 0
|
||||
})
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func compareEntries(left Entry, right Entry, sortBy string) int {
|
||||
switch strings.ToLower(strings.TrimSpace(sortBy)) {
|
||||
case "size":
|
||||
if left.Size != right.Size {
|
||||
return cmpInt64(left.Size, right.Size)
|
||||
}
|
||||
case "modified":
|
||||
if !left.ModifiedAt.Equal(right.ModifiedAt) {
|
||||
return cmpTimeDesc(left.ModifiedAt, right.ModifiedAt)
|
||||
}
|
||||
case "created":
|
||||
if left.CreatedKnown != right.CreatedKnown {
|
||||
if left.CreatedKnown {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if !left.CreatedAt.Equal(right.CreatedAt) {
|
||||
return cmpTimeDesc(left.CreatedAt, right.CreatedAt)
|
||||
}
|
||||
case "extension":
|
||||
if left.Extension != right.Extension {
|
||||
return strings.Compare(left.Extension, right.Extension)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Compare(strings.ToLower(left.Name), strings.ToLower(right.Name))
|
||||
}
|
||||
|
||||
func cmpInt64(left int64, right int64) int {
|
||||
switch {
|
||||
case left < right:
|
||||
return -1
|
||||
case left > right:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func cmpTimeDesc(left time.Time, right time.Time) int {
|
||||
switch {
|
||||
case left.Equal(right):
|
||||
return 0
|
||||
case left.After(right):
|
||||
return -1
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func statBirthTime(path string) (time.Time, bool) {
|
||||
var stx unix.Statx_t
|
||||
if err := unix.Statx(unix.AT_FDCWD, path, unix.AT_STATX_SYNC_AS_STAT, unix.STATX_BTIME, &stx); err == nil {
|
||||
if stx.Mask&unix.STATX_BTIME != 0 {
|
||||
return time.Unix(int64(stx.Btime.Sec), int64(stx.Btime.Nsec)), true
|
||||
}
|
||||
}
|
||||
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
seconds := int64(stat.Ctim.Sec)
|
||||
nanos := int64(stat.Ctim.Nsec)
|
||||
if seconds == 0 && nanos == 0 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return time.Unix(seconds, nanos), true
|
||||
}
|
||||
|
||||
func DirectorySize(path string) (int64, error) {
|
||||
var total int64
|
||||
|
||||
err := filepath.WalkDir(path, func(current string, d fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
total += info.Size()
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func FindSelected(entries []Entry, key string) int {
|
||||
for idx, entry := range entries {
|
||||
if entry.MatchKey() == key {
|
||||
return idx
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func HumanSize(size int64) string {
|
||||
if size < 0 {
|
||||
return "?"
|
||||
}
|
||||
if size < 1024 {
|
||||
return fmt.Sprintf("%d B", size)
|
||||
}
|
||||
units := []string{"KB", "MB", "GB", "TB"}
|
||||
value := float64(size)
|
||||
for _, unit := range units {
|
||||
value /= 1024
|
||||
if value < 1024 {
|
||||
return fmt.Sprintf("%.1f %s", value, unit)
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%.1f PB", value/1024)
|
||||
}
|
||||
|
||||
func ShortTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "n/a"
|
||||
}
|
||||
return t.Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
func Permissions(mode fs.FileMode) string {
|
||||
return mode.String()
|
||||
}
|
||||
|
||||
func IsBinarySample(data []byte) bool {
|
||||
if len(data) == 0 {
|
||||
return false
|
||||
}
|
||||
var controls int
|
||||
for _, b := range data {
|
||||
if b == 0 {
|
||||
return true
|
||||
}
|
||||
if b < 9 || (b > 13 && b < 32) {
|
||||
controls++
|
||||
}
|
||||
}
|
||||
return controls > len(data)/10
|
||||
}
|
||||
|
||||
func SafeBase(path string) string {
|
||||
base := filepath.Base(path)
|
||||
if base == "." || base == string(filepath.Separator) {
|
||||
return path
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func JoinPath(path string, name string) string {
|
||||
return filepath.Join(path, name)
|
||||
}
|
||||
|
||||
func ErrOverwrite(path string) error {
|
||||
return fmt.Errorf("target already exists: %s", path)
|
||||
}
|
||||
|
||||
func IsNotExist(err error) bool {
|
||||
return errors.Is(err, os.ErrNotExist)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue