Initial vcom TUI prototype

This commit is contained in:
vrubelroman 2026-04-22 22:10:50 +03:00
commit 059f925e00
16 changed files with 3227 additions and 0 deletions

95
internal/fs/entry.go Normal file
View 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
View 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
View 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
View 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)
}