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

215
internal/config/config.go Normal file
View file

@ -0,0 +1,215 @@
package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
toml "github.com/pelletier/go-toml/v2"
)
type Config struct {
Startup StartupConfig `toml:"startup"`
UI UIConfig `toml:"ui"`
Browser BrowserConfig `toml:"browser"`
Preview PreviewConfig `toml:"preview"`
Behavior BehaviorConfig `toml:"behavior"`
}
type StartupConfig struct {
LeftPath string `toml:"left_path"`
RightPath string `toml:"right_path"`
}
type UIConfig struct {
AppTitle string `toml:"app_title"`
Theme string `toml:"theme"`
ShowTitleBar bool `toml:"show_title_bar"`
ShowFooter bool `toml:"show_footer"`
Border string `toml:"border"`
PathDisplay string `toml:"path_display"`
PaneGap int `toml:"pane_gap"`
CenterWidthPercent int `toml:"center_width_percent"`
}
type BrowserConfig struct {
ShowHidden bool `toml:"show_hidden"`
DirsFirst bool `toml:"dirs_first"`
HumanReadableSize bool `toml:"human_readable_size"`
Sort SortConfig `toml:"sort"`
Columns BrowserColumnsConfig `toml:"columns"`
}
type SortConfig struct {
By string `toml:"by"`
Reverse bool `toml:"reverse"`
}
type BrowserColumnsConfig struct {
Name bool `toml:"name"`
Size bool `toml:"size"`
Modified bool `toml:"modified"`
Created bool `toml:"created"`
Permissions bool `toml:"permissions"`
Extension bool `toml:"extension"`
}
type PreviewConfig struct {
ShowMetadata bool `toml:"show_metadata"`
WrapText bool `toml:"wrap_text"`
MaxPreviewBytes int64 `toml:"max_preview_bytes"`
DirectoryPreviewLimit int `toml:"directory_preview_limit"`
}
type BehaviorConfig struct {
ConfirmDelete bool `toml:"confirm_delete"`
ConfirmOverwrite bool `toml:"confirm_overwrite"`
CalculateDirSizeOnSpace bool `toml:"calculate_dir_size_on_space"`
FollowSymlinks bool `toml:"follow_symlinks"`
}
func Default() Config {
return Config{
Startup: StartupConfig{},
UI: UIConfig{
AppTitle: "vcom",
Theme: "catppuccin-mocha",
ShowTitleBar: true,
ShowFooter: true,
Border: "rounded",
PathDisplay: "short",
PaneGap: 1,
CenterWidthPercent: 30,
},
Browser: BrowserConfig{
ShowHidden: true,
DirsFirst: true,
HumanReadableSize: true,
Sort: SortConfig{
By: "name",
Reverse: false,
},
Columns: BrowserColumnsConfig{
Name: true,
Size: true,
Modified: true,
},
},
Preview: PreviewConfig{
ShowMetadata: true,
WrapText: false,
MaxPreviewBytes: 64 * 1024,
DirectoryPreviewLimit: 80,
},
Behavior: BehaviorConfig{
ConfirmDelete: true,
ConfirmOverwrite: true,
CalculateDirSizeOnSpace: true,
FollowSymlinks: false,
},
}
}
func Load(explicitPath string) (Config, string, error) {
cfg := Default()
path, found, err := resolvePath(explicitPath)
if err != nil {
return Config{}, "", err
}
if !found {
return cfg, "", nil
}
data, err := os.ReadFile(path)
if err != nil {
return Config{}, "", fmt.Errorf("read %s: %w", path, err)
}
if err := toml.Unmarshal(data, &cfg); err != nil {
return Config{}, "", fmt.Errorf("parse %s: %w", path, err)
}
if err := cfg.Validate(); err != nil {
return Config{}, "", fmt.Errorf("validate %s: %w", path, err)
}
return cfg, path, nil
}
func (c *Config) Validate() error {
if c.UI.Theme == "" {
return errors.New("ui.theme must not be empty")
}
if strings.TrimSpace(c.UI.AppTitle) == "" {
c.UI.AppTitle = "vcom"
}
if c.Preview.MaxPreviewBytes <= 0 {
return errors.New("preview.max_preview_bytes must be > 0")
}
if c.Preview.DirectoryPreviewLimit <= 0 {
return errors.New("preview.directory_preview_limit must be > 0")
}
if !c.Browser.Columns.Name {
return errors.New("browser.columns.name must stay enabled")
}
if strings.TrimSpace(c.UI.PathDisplay) == "" {
c.UI.PathDisplay = "short"
}
if c.UI.PaneGap < 0 || c.UI.PaneGap > 4 {
return errors.New("ui.pane_gap must be between 0 and 4")
}
if c.UI.CenterWidthPercent < 20 || c.UI.CenterWidthPercent > 60 {
return errors.New("ui.center_width_percent must be between 20 and 60")
}
switch strings.ToLower(strings.TrimSpace(c.Browser.Sort.By)) {
case "", "name":
c.Browser.Sort.By = "name"
case "modified", "size", "created", "extension":
default:
return fmt.Errorf("browser.sort.by must be one of: name, modified, size, created, extension")
}
return nil
}
func resolvePath(explicitPath string) (string, bool, error) {
var candidates []string
if explicitPath != "" {
candidates = append(candidates, explicitPath)
} else {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", false, fmt.Errorf("resolve home dir: %w", err)
}
xdgDir := os.Getenv("XDG_CONFIG_HOME")
if xdgDir == "" {
xdgDir = filepath.Join(homeDir, ".config")
}
candidates = append(candidates,
"vcom.toml",
filepath.Join("config", "vcom.toml"),
filepath.Join(xdgDir, "vcom", "vcom.toml"),
filepath.Join(homeDir, ".config", "vcom", "vcom.toml"),
)
}
for _, candidate := range candidates {
if candidate == "" {
continue
}
absPath, err := filepath.Abs(candidate)
if err != nil {
return "", false, fmt.Errorf("resolve %s: %w", candidate, err)
}
if _, err := os.Stat(absPath); err == nil {
return absPath, true, nil
} else if !errors.Is(err, os.ErrNotExist) {
return "", false, fmt.Errorf("stat %s: %w", absPath, err)
}
}
return "", false, nil
}

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)
}

157
internal/theme/theme.go Normal file
View file

@ -0,0 +1,157 @@
package theme
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
type Palette struct {
Name string
Background lipgloss.Color
Panel lipgloss.Color
PanelInactive lipgloss.Color
PanelElevated lipgloss.Color
Border lipgloss.Color
BorderActive lipgloss.Color
Text lipgloss.Color
Muted lipgloss.Color
Accent lipgloss.Color
Selection lipgloss.Color
Warning lipgloss.Color
Danger lipgloss.Color
Folder lipgloss.Color
TextFile lipgloss.Color
ConfigFile lipgloss.Color
ExecFile lipgloss.Color
ImageFile lipgloss.Color
BinaryFile lipgloss.Color
FooterKey lipgloss.Color
}
var builtInThemes = []string{
"catppuccin-mocha",
"tokyo-night",
"gruvbox-dark",
"nord-frost",
}
func Names() []string {
values := make([]string, len(builtInThemes))
copy(values, builtInThemes)
return values
}
func Next(current string) string {
values := Names()
if len(values) == 0 {
return current
}
current = strings.ToLower(strings.TrimSpace(current))
for idx, value := range values {
if value == current {
return values[(idx+1)%len(values)]
}
}
return values[0]
}
func Resolve(name string) (Palette, error) {
switch strings.ToLower(name) {
case "catppuccin-mocha":
return Palette{
Name: "catppuccin-mocha",
Background: lipgloss.Color("#11111B"),
Panel: lipgloss.Color("#181825"),
PanelInactive: lipgloss.Color("#1E1E2E"),
PanelElevated: lipgloss.Color("#24273A"),
Border: lipgloss.Color("#45475A"),
BorderActive: lipgloss.Color("#89B4FA"),
Text: lipgloss.Color("#CDD6F4"),
Muted: lipgloss.Color("#A6ADC8"),
Accent: lipgloss.Color("#F5C2E7"),
Selection: lipgloss.Color("#313244"),
Warning: lipgloss.Color("#F9E2AF"),
Danger: lipgloss.Color("#F38BA8"),
Folder: lipgloss.Color("#89B4FA"),
TextFile: lipgloss.Color("#A6E3A1"),
ConfigFile: lipgloss.Color("#F9E2AF"),
ExecFile: lipgloss.Color("#FAB387"),
ImageFile: lipgloss.Color("#94E2D5"),
BinaryFile: lipgloss.Color("#CBA6F7"),
FooterKey: lipgloss.Color("#89DCEB"),
}, nil
case "tokyo-night":
return Palette{
Name: "tokyo-night",
Background: lipgloss.Color("#16161E"),
Panel: lipgloss.Color("#1A1B26"),
PanelInactive: lipgloss.Color("#24283B"),
PanelElevated: lipgloss.Color("#2A2F44"),
Border: lipgloss.Color("#3B4261"),
BorderActive: lipgloss.Color("#7AA2F7"),
Text: lipgloss.Color("#C0CAF5"),
Muted: lipgloss.Color("#9AA5CE"),
Accent: lipgloss.Color("#BB9AF7"),
Selection: lipgloss.Color("#292E42"),
Warning: lipgloss.Color("#E0AF68"),
Danger: lipgloss.Color("#F7768E"),
Folder: lipgloss.Color("#7AA2F7"),
TextFile: lipgloss.Color("#9ECE6A"),
ConfigFile: lipgloss.Color("#E0AF68"),
ExecFile: lipgloss.Color("#FF9E64"),
ImageFile: lipgloss.Color("#73DACA"),
BinaryFile: lipgloss.Color("#BB9AF7"),
FooterKey: lipgloss.Color("#73DACA"),
}, nil
case "gruvbox-dark":
return Palette{
Name: "gruvbox-dark",
Background: lipgloss.Color("#1D2021"),
Panel: lipgloss.Color("#282828"),
PanelInactive: lipgloss.Color("#32302F"),
PanelElevated: lipgloss.Color("#3C3836"),
Border: lipgloss.Color("#504945"),
BorderActive: lipgloss.Color("#FABD2F"),
Text: lipgloss.Color("#EBDBB2"),
Muted: lipgloss.Color("#BDAE93"),
Accent: lipgloss.Color("#83A598"),
Selection: lipgloss.Color("#3C3836"),
Warning: lipgloss.Color("#FE8019"),
Danger: lipgloss.Color("#FB4934"),
Folder: lipgloss.Color("#83A598"),
TextFile: lipgloss.Color("#B8BB26"),
ConfigFile: lipgloss.Color("#FABD2F"),
ExecFile: lipgloss.Color("#FE8019"),
ImageFile: lipgloss.Color("#8EC07C"),
BinaryFile: lipgloss.Color("#D3869B"),
FooterKey: lipgloss.Color("#8EC07C"),
}, nil
case "nord-frost":
return Palette{
Name: "nord-frost",
Background: lipgloss.Color("#2E3440"),
Panel: lipgloss.Color("#3B4252"),
PanelInactive: lipgloss.Color("#434C5E"),
PanelElevated: lipgloss.Color("#4C566A"),
Border: lipgloss.Color("#4C566A"),
BorderActive: lipgloss.Color("#88C0D0"),
Text: lipgloss.Color("#ECEFF4"),
Muted: lipgloss.Color("#D8DEE9"),
Accent: lipgloss.Color("#81A1C1"),
Selection: lipgloss.Color("#434C5E"),
Warning: lipgloss.Color("#EBCB8B"),
Danger: lipgloss.Color("#BF616A"),
Folder: lipgloss.Color("#81A1C1"),
TextFile: lipgloss.Color("#A3BE8C"),
ConfigFile: lipgloss.Color("#EBCB8B"),
ExecFile: lipgloss.Color("#D08770"),
ImageFile: lipgloss.Color("#8FBCBB"),
BinaryFile: lipgloss.Color("#B48EAD"),
FooterKey: lipgloss.Color("#8FBCBB"),
}, nil
default:
return Palette{}, fmt.Errorf("unknown theme %q", name)
}
}

67
internal/ui/keymap.go Normal file
View file

@ -0,0 +1,67 @@
package ui
import "github.com/charmbracelet/bubbles/key"
type KeyMap struct {
View key.Binding
Edit key.Binding
Info key.Binding
ToggleHidden key.Binding
CycleTheme key.Binding
CycleSort key.Binding
Up key.Binding
Down key.Binding
PageUp key.Binding
PageDown key.Binding
Open key.Binding
Back key.Binding
Switch key.Binding
Refresh key.Binding
DirSize key.Binding
Copy key.Binding
Move key.Binding
Mkdir key.Binding
Delete key.Binding
Confirm key.Binding
Cancel key.Binding
Quit key.Binding
}
func DefaultKeyMap() KeyMap {
return KeyMap{
View: key.NewBinding(key.WithKeys("f3", "v"), key.WithHelp("F3/v", "view")),
Edit: key.NewBinding(key.WithKeys("f4", "e"), key.WithHelp("F4/e", "edit")),
Info: key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "info")),
ToggleHidden: key.NewBinding(key.WithKeys("."), key.WithHelp(".", "hidden")),
CycleTheme: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "theme")),
CycleSort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")),
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
PageUp: key.NewBinding(key.WithKeys("pgup", "b"), key.WithHelp("PgUp/b", "page up")),
PageDown: key.NewBinding(key.WithKeys("pgdown", "f"), key.WithHelp("PgDn/f", "page down")),
Open: key.NewBinding(key.WithKeys("enter", "right", "l"), key.WithHelp("Enter", "open")),
Back: key.NewBinding(key.WithKeys("backspace", "left", "h"), key.WithHelp("←", "parent")),
Switch: key.NewBinding(key.WithKeys("tab"), key.WithHelp("Tab", "switch pane")),
Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
DirSize: key.NewBinding(key.WithKeys(" "), key.WithHelp("Space", "dir size")),
Copy: key.NewBinding(key.WithKeys("f5", "c"), key.WithHelp("F5/c", "copy")),
Move: key.NewBinding(key.WithKeys("f6", "m"), key.WithHelp("F6/m", "move")),
Mkdir: key.NewBinding(key.WithKeys("f7", "n"), key.WithHelp("F7/n", "mkdir")),
Delete: key.NewBinding(key.WithKeys("f8", "delete", "x"), key.WithHelp("F8/x", "delete")),
Confirm: key.NewBinding(key.WithKeys("enter", "y"), key.WithHelp("Enter/y", "confirm")),
Cancel: key.NewBinding(key.WithKeys("esc", "n"), key.WithHelp("Esc/n", "cancel")),
Quit: key.NewBinding(key.WithKeys("f10", "q", "ctrl+c"), key.WithHelp("F10/q", "quit")),
}
}
func (k KeyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Info, k.Copy, k.Move, k.Mkdir, k.Delete, k.Quit}
}
func (k KeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.Up, k.Down, k.Open, k.Back, k.Switch, k.Info},
{k.View, k.Edit, k.Copy, k.Move, k.Mkdir, k.Delete},
{k.DirSize, k.Refresh, k.ToggleHidden, k.CycleSort, k.CycleTheme, k.Quit},
}
}

1115
internal/ui/model.go Normal file

File diff suppressed because it is too large Load diff

472
internal/ui/pane.go Normal file
View file

@ -0,0 +1,472 @@
package ui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"vcom/internal/config"
vfs "vcom/internal/fs"
"vcom/internal/theme"
)
type PaneID string
const (
PaneLeft PaneID = "left"
PaneRight PaneID = "right"
)
type BrowserPane struct {
ID PaneID
Path string
Entries []vfs.Entry
Cursor int
Offset int
}
func (p *BrowserPane) Selected() (vfs.Entry, bool) {
if len(p.Entries) == 0 || p.Cursor < 0 || p.Cursor >= len(p.Entries) {
return vfs.Entry{}, false
}
return p.Entries[p.Cursor], true
}
func (p *BrowserPane) SetEntries(entries []vfs.Entry, preserveKey string) {
p.Entries = entries
if len(entries) == 0 {
p.Cursor = 0
p.Offset = 0
return
}
if preserveKey != "" {
p.Cursor = vfs.FindSelected(entries, preserveKey)
}
if p.Cursor >= len(entries) {
p.Cursor = len(entries) - 1
}
if p.Cursor < 0 {
p.Cursor = 0
}
if p.Offset > p.Cursor {
p.Offset = p.Cursor
}
}
func (p *BrowserPane) Move(delta int, pageSize int) {
if len(p.Entries) == 0 {
p.Cursor = 0
return
}
p.Cursor += delta
if p.Cursor < 0 {
p.Cursor = 0
}
if p.Cursor >= len(p.Entries) {
p.Cursor = len(p.Entries) - 1
}
if p.Cursor < p.Offset {
p.Offset = p.Cursor
}
if pageSize > 0 && p.Cursor >= p.Offset+pageSize {
p.Offset = p.Cursor - pageSize + 1
}
if p.Offset < 0 {
p.Offset = 0
}
}
func (p *BrowserPane) EnsureVisible(pageSize int) {
if pageSize <= 0 {
return
}
if p.Cursor < p.Offset {
p.Offset = p.Cursor
}
if p.Cursor >= p.Offset+pageSize {
p.Offset = p.Cursor - pageSize + 1
}
if p.Offset < 0 {
p.Offset = 0
}
}
func renderPane(
pane BrowserPane,
cfg config.Config,
palette theme.Palette,
width int,
height int,
active bool,
) string {
if width <= 0 || height <= 0 {
return ""
}
borderColor := palette.Border
headerBg := palette.PanelInactive
if active {
borderColor = palette.BorderActive
headerBg = palette.Selection
}
innerWidth := max(width-2, 1)
box := lipgloss.NewStyle().
Width(width).
Height(height).
Background(palette.PanelInactive).
Foreground(palette.Text).
BorderStyle(borderStyle(cfg.UI.Border)).
BorderForeground(borderColor)
header := lipgloss.NewStyle().
Render(renderPaneHeader(pane, cfg, palette, innerWidth, active, headerBg))
rowsHeight := max(height-4, 1)
headerRow := renderColumnsHeader(cfg, innerWidth, palette)
rows := renderPaneRows(pane, cfg, palette, innerWidth, rowsHeight, active)
content := lipgloss.JoinVertical(lipgloss.Left, header, headerRow, rows)
return box.Render(content)
}
func renderPaneHeader(pane BrowserPane, cfg config.Config, palette theme.Palette, width int, active bool, headerBg lipgloss.Color) string {
label := strings.ToUpper(string(pane.ID))
labelStyle := lipgloss.NewStyle().
Foreground(palette.Background).
Background(palette.FooterKey).
Bold(true).
Padding(0, 1)
if active {
labelStyle = labelStyle.Background(palette.Accent)
}
labelView := labelStyle.Render(label)
pathWidth := max(width-lipgloss.Width(labelView)-1, 4)
pathStyle := lipgloss.NewStyle().
Width(pathWidth).
Background(headerBg).
Foreground(palette.Text).
Bold(active)
return lipgloss.NewStyle().
Width(width).
Background(headerBg).
Render(labelView + " " + pathStyle.Render(truncateMiddle(compactPath(pane.Path, cfg.UI.PathDisplay), pathWidth)))
}
func renderColumnsHeader(cfg config.Config, width int, palette theme.Palette) string {
columns := buildColumns(cfg, width)
parts := make([]string, 0, len(columns))
for _, column := range columns {
style := lipgloss.NewStyle().
Width(column.Width).
Foreground(palette.Muted).
Bold(true)
if column.AlignRight {
style = style.Align(lipgloss.Right)
}
parts = append(parts, style.Render(truncateRight(column.Title, column.Width)))
}
return lipgloss.NewStyle().
Width(width).
Background(palette.Panel).
Render(strings.Join(parts, " "))
}
func renderPaneRows(pane BrowserPane, cfg config.Config, palette theme.Palette, width int, height int, active bool) string {
if len(pane.Entries) == 0 {
return lipgloss.NewStyle().
Width(width).
Height(height).
Padding(1, 1).
Foreground(palette.Muted).
Render("Empty directory")
}
visibleHeight := max(height, 1)
pane.EnsureVisible(visibleHeight)
end := min(len(pane.Entries), pane.Offset+visibleHeight)
lines := make([]string, 0, visibleHeight)
for idx := pane.Offset; idx < end; idx++ {
entry := pane.Entries[idx]
row := renderEntryRow(entry, cfg, width, idx == pane.Cursor, active, palette)
lines = append(lines, row)
}
for len(lines) < visibleHeight {
lines = append(lines, lipgloss.NewStyle().Width(width).Render(""))
}
return lipgloss.NewStyle().
Width(width).
Render(strings.Join(lines, "\n"))
}
func renderEntryRow(entry vfs.Entry, cfg config.Config, width int, selected bool, active bool, palette theme.Palette) string {
columns := buildColumns(cfg, width)
parts := make([]string, 0, len(columns))
for _, column := range columns {
value := column.Value(entry, cfg.Browser.HumanReadableSize)
style := lipgloss.NewStyle().
Width(column.Width).
Foreground(entryColor(entry, palette))
if entry.IsHidden {
style = style.Foreground(palette.Muted)
}
if column.AlignRight {
style = style.Align(lipgloss.Right)
}
parts = append(parts, style.Render(truncateForColumn(value, column.Width, column.AlignRight)))
}
rowStyle := lipgloss.NewStyle().Width(width)
if selected {
rowStyle = rowStyle.Background(palette.Selection)
if active {
rowStyle = rowStyle.Bold(true)
}
}
return rowStyle.Render(strings.Join(parts, " "))
}
type columnSpec struct {
Key string
Title string
Width int
MinWidth int
AlignRight bool
Value func(entry vfs.Entry, human bool) string
}
func buildColumns(cfg config.Config, totalWidth int) []columnSpec {
fixed := []columnSpec{}
if cfg.Browser.Columns.Permissions {
fixed = append(fixed, columnSpec{
Key: "permissions",
Title: "Perms",
Width: 10,
MinWidth: 9,
Value: func(entry vfs.Entry, _ bool) string {
return vfs.Permissions(entry.Mode)
},
})
}
if cfg.Browser.Columns.Extension {
fixed = append(fixed, columnSpec{
Key: "extension",
Title: "Ext",
Width: 6,
MinWidth: 4,
Value: func(entry vfs.Entry, _ bool) string {
if entry.IsDir {
return ""
}
return entry.Extension
},
})
}
if cfg.Browser.Columns.Size {
fixed = append(fixed, columnSpec{
Key: "size",
Title: "Size",
Width: 10,
MinWidth: 7,
AlignRight: true,
Value: func(entry vfs.Entry, human bool) string {
if entry.IsDir {
if !entry.DirSizeKnown {
return ""
}
if human {
return vfs.HumanSize(entry.Size)
}
return fmt.Sprintf("%d", entry.Size)
}
if human {
return vfs.HumanSize(entry.Size)
}
return fmt.Sprintf("%d", entry.Size)
},
})
}
if cfg.Browser.Columns.Created {
fixed = append(fixed, columnSpec{
Key: "created",
Title: "Created",
Width: 16,
MinWidth: 10,
Value: func(entry vfs.Entry, _ bool) string {
if !entry.CreatedKnown {
return "n/a"
}
return vfs.ShortTime(entry.CreatedAt)
},
})
}
if cfg.Browser.Columns.Modified {
fixed = append(fixed, columnSpec{
Key: "modified",
Title: "Modified",
Width: 16,
MinWidth: 10,
Value: func(entry vfs.Entry, _ bool) string {
return vfs.ShortTime(entry.ModifiedAt)
},
})
}
minNameWidth := 12
gaps := max(len(fixed), 0)
availableForColumns := totalWidth - gaps
if availableForColumns < minNameWidth {
availableForColumns = minNameWidth
}
fixedWidth := 0
for _, column := range fixed {
fixedWidth += column.Width
}
for fixedWidth+minNameWidth > availableForColumns {
changed := false
for idx := len(fixed) - 1; idx >= 0 && fixedWidth+minNameWidth > availableForColumns; idx-- {
if fixed[idx].Width > fixed[idx].MinWidth {
fixed[idx].Width--
fixedWidth--
changed = true
}
}
if !changed {
break
}
}
nameWidth := max(availableForColumns-fixedWidth, minNameWidth)
name := columnSpec{
Key: "name",
Title: "Name",
Width: nameWidth,
MinWidth: minNameWidth,
Value: func(entry vfs.Entry, _ bool) string {
return entryIcon(entry) + " " + entry.DisplayName()
},
}
return append([]columnSpec{name}, fixed...)
}
func borderStyle(value string) lipgloss.Border {
switch strings.ToLower(value) {
case "double":
return lipgloss.DoubleBorder()
case "thick":
return lipgloss.ThickBorder()
default:
return lipgloss.RoundedBorder()
}
}
func compactPath(path string, mode string) string {
switch strings.ToLower(mode) {
case "full":
return path
case "smart":
return smartPath(path, 42)
default:
return vfs.SafeBase(path)
}
}
func smartPath(path string, maxWidth int) string {
if lipgloss.Width(path) <= maxWidth {
return path
}
return truncateMiddle(path, maxWidth)
}
func truncateMiddle(value string, maxWidth int) string {
if maxWidth <= 0 || lipgloss.Width(value) <= maxWidth {
return value
}
if maxWidth <= 3 {
return value[:maxWidth]
}
left := maxWidth/2 - 1
right := maxWidth - left - 1
if left < 1 {
left = 1
}
if right < 1 {
right = 1
}
return value[:left] + "…" + value[len(value)-right:]
}
func truncateRight(value string, maxWidth int) string {
if maxWidth <= 0 || lipgloss.Width(value) <= maxWidth {
return value
}
if maxWidth == 1 {
return value[:1]
}
return value[:maxWidth-1] + "…"
}
func truncateForColumn(value string, maxWidth int, alignRight bool) string {
if lipgloss.Width(value) <= maxWidth {
return value
}
if alignRight {
if maxWidth <= 1 {
return value[len(value)-1:]
}
if len(value) <= maxWidth {
return value
}
return "…" + value[len(value)-maxWidth+1:]
}
return truncateRight(value, maxWidth)
}
func entryIcon(entry vfs.Entry) string {
switch entry.Category() {
case "parent":
return "↩"
case "directory":
return ""
case "config":
return ""
case "text":
return "󰈙"
case "image":
return "󰋩"
case "executable":
return "󰆍"
case "archive":
return ""
default:
return "󰈔"
}
}
func entryColor(entry vfs.Entry, palette theme.Palette) lipgloss.Color {
switch entry.Category() {
case "directory", "parent":
return palette.Folder
case "config":
return palette.ConfigFile
case "text":
return palette.TextFile
case "image":
return palette.ImageFile
case "executable":
return palette.ExecFile
default:
return palette.BinaryFile
}
}