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
}