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

0
.codex Normal file
View file

90
README.md Normal file
View file

@ -0,0 +1,90 @@
# vcom
`vcom` is a terminal file manager inspired by Midnight Commander and built on top of Charm's TUI stack.
The layout is:
- left browser pane
- right browser pane
The key difference from classic `mc` is the inspect mode on `i`. It temporarily replaces the inactive pane with a preview/info panel for the active side selection:
- directory: child entries
- text file: text preview
- image: metadata and dimensions
- binary or unsupported file: safe fallback preview
## Goals
- MC-like browsing and function-key workflow
- configurable column layout
- pleasant default styling
- readable, editable config with commented-out optional features
- clean architecture for async file operations and previews
## Current feature set
- MC-like two-pane layout by default
- preview/info mode on `i`
- left/right file browsers
- preview with top metadata widget
- themes with `catppuccin-mocha` as default
- runtime theme cycling with `t`
- configurable visible columns
- hidden files shown by default
- hidden files toggle with `.`
- browser sort cycling with `s`
- directory size calculation on `Space`
- overwrite confirmation before copy or move
- pager on `F3` and editor on `F4` when available
- basic `F5` copy, `F6` move, `F7` mkdir, `F8` delete, `F10` quit
## Controls
- `Tab`: switch active browser pane
- `Enter`: open directory
- `Backspace` or `Left`: go to parent directory
- `i`: replace inactive pane with info/preview for the active pane selection
- `Space`: calculate selected directory size
- `.`: toggle hidden entries
- `s`: cycle sort mode
- `t`: cycle theme
- `F3`: open selected file in `$PAGER` if available, otherwise stay in center preview
- `F4`: open selected file in `$EDITOR`
- `F5`: copy to passive pane path
- `F6`: move to passive pane path
- `F7`: create directory in active pane
- `F8`: delete selected entry
- `F10`: quit
## Run
```bash
go run ./cmd/vcom
```
Optional config file lookup order:
1. `-config /path/to/vcom.toml`
2. `./vcom.toml`
3. `./config/vcom.toml`
4. `$XDG_CONFIG_HOME/vcom/vcom.toml`
5. `~/.config/vcom/vcom.toml`
## Themes
Built-in presets:
- `catppuccin-mocha`
- `tokyo-night`
- `gruvbox-dark`
- `nord-frost`
The sample config in [vcom.toml](/home/vrubel/projects/vcom/vcom.toml) is intentionally written so optional columns and sort variants can be uncommented by hand.
## Notes
- File creation time depends on filesystem and OS support. When unavailable the UI shows `n/a`.
- Real inline image rendering is intentionally not enabled yet because terminal support is fragmented. The current architecture keeps that as an isolated preview backend to add later.
More detail is in [docs/architecture.md](/home/vrubel/projects/vcom/docs/architecture.md).

35
cmd/vcom/main.go Normal file
View file

@ -0,0 +1,35 @@
package main
import (
"flag"
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
"vcom/internal/config"
"vcom/internal/ui"
)
func main() {
configPath := flag.String("config", "", "path to vcom TOML config")
flag.Parse()
cfg, resolvedPath, err := config.Load(*configPath)
if err != nil {
fmt.Fprintf(os.Stderr, "config error: %v\n", err)
os.Exit(1)
}
model, err := ui.NewModel(cfg, resolvedPath)
if err != nil {
fmt.Fprintf(os.Stderr, "startup error: %v\n", err)
os.Exit(1)
}
program := tea.NewProgram(model, tea.WithAltScreen())
if _, err := program.Run(); err != nil {
fmt.Fprintf(os.Stderr, "runtime error: %v\n", err)
os.Exit(1)
}
}

196
docs/architecture.md Normal file
View file

@ -0,0 +1,196 @@
# Architecture
## Why this shape
The project should not become a giant `main.go` that mixes rendering, key handling, filesystem I/O and business rules. The architecture is split so the UI remains reactive and file operations stay isolated.
## High-level modules
- `cmd/vcom`
Entry point, config loading, program startup.
- `internal/config`
Config schema, defaults, search paths and TOML parsing.
- `internal/fs`
Filesystem model, directory scanning, previews and file operations.
- `internal/theme`
Theme presets and style tokens.
- `internal/ui`
Bubble Tea model, key map, pane rendering, modal flow and layout.
## State model
The Bubble Tea root model owns:
- terminal dimensions
- full config
- active browser pane
- left and right pane state
- center preview state
- transient modal state
- busy/status state for async work
This keeps the Elm-style update loop simple:
1. key or resize event arrives
2. model decides whether to mutate local state or start async work
3. async work returns a typed message
4. view renders from plain state
## Pane model
Each side pane stores:
- current path
- scanned entries
- cursor index
- scroll offset
- cached directory sizes for entries calculated on demand
This is intentionally independent from rendering details, so the pane can be unit-tested later without Lip Gloss.
## Preview pipeline
The center pane is driven only by the current selection from the active side pane.
Preview strategies:
- directory preview
Shows child entries and summary data.
- text preview
Reads up to a configured byte limit and displays text safely.
- image preview
Detects dimensions and format through standard Go image decoders.
- binary fallback
Avoids dumping junk bytes into the terminal.
Metadata shown above the preview:
- kind
- full path
- size
- created time
- modified time
- permissions
Directory size is expensive, so it is not calculated eagerly. Pressing `Space` starts an async scan and updates both:
- side-pane size column
- preview metadata widget
## Configuration design
TOML is used because:
- it is readable in terminal workflows
- comments are first-class
- optional settings can stay commented out for manual enable/disable
The example config enables only MC-like columns by default:
- `name`
- `size`
- `modified`
Additional columns are left commented out so users can literally uncomment them:
- `created`
- `permissions`
- `extension`
Other useful config areas:
- startup paths for left and right panes
- theme preset
- hidden file visibility
- sort field and reverse mode
- center pane width ratio
- confirmation behavior
- preview byte limits
- text wrapping in preview
- compact path display
## Rendering strategy
The side panes are custom-rendered rather than built on top of the generic table bubble.
Reasoning:
- MC-like directory panes are not just tables
- we need tight control over selection styling, truncation and empty states
- the center pane uses a different rendering model than the side panes
Bubble usage:
- `bubbletea`
event loop and async commands
- `bubbles/key`
declarative key bindings
- `bubbles/textinput`
mkdir modal
- `bubbles/viewport`
preview scrolling surface
- `lipgloss`
all layout and styling
## Operations
The first operational slice is intentionally MC-core:
- copy selected entry to passive pane directory
- move selected entry to passive pane directory
- create directory in active pane
- delete selected entry
- refresh active pane
These operations are dispatched asynchronously to avoid freezing the TUI on large directories.
Overwrite handling is decided before the async operation begins:
- if the target does not exist, the operation runs directly
- if the target exists and overwrite confirmation is enabled, the UI opens a modal
- if overwrite confirmation is disabled, the operation replaces the target immediately
## Theme system
Themes are token-based rather than style-object based.
Each preset defines semantic colors:
- background
- panel
- panel inactive
- border
- border active
- text
- muted text
- accent
- selection
- warning
- danger
- footer key
This allows future user-defined themes without touching UI logic.
The current UI also supports runtime theme cycling, but config remains the source of truth for default startup appearance.
## Extension points
The architecture is designed to accept later additions without a rewrite:
- multi-select and batch operations
- file viewer/editor integration
- terminal image protocols
- tab history per pane
- bookmarks and quick-jump
- sort modes
- archive browsing
- search/filter overlay
- pluggable preview providers
## Constraints worth keeping
- never calculate directory sizes during normal navigation
- never read full large files into memory for preview
- keep filesystem code out of `View()`
- keep styling decisions out of `internal/fs`
- prefer typed Tea messages over stringly-typed status events

31
go.mod Normal file
View file

@ -0,0 +1,31 @@
module vcom
go 1.26.0
require (
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/pelletier/go-toml/v2 v2.2.4
golang.org/x/sys v0.38.0
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/text v0.3.8 // indirect
)

49
go.sum Normal file
View file

@ -0,0 +1,49 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=

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

50
vcom.toml Normal file
View file

@ -0,0 +1,50 @@
[startup]
# left_path = "~/Downloads"
# right_path = "~/Projects"
[ui]
app_title = "vcom"
theme = "catppuccin-mocha"
show_title_bar = true
show_footer = true
border = "rounded"
path_display = "smart"
pane_gap = 1
center_width_percent = 30
[browser]
show_hidden = true
dirs_first = true
human_readable_size = true
[browser.sort]
by = "name"
reverse = false
# by = "modified"
# by = "size"
# reverse = true
[browser.columns]
name = true
size = true
modified = true
# created = true
# permissions = true
# extension = true
[preview]
show_metadata = true
wrap_text = false
max_preview_bytes = 65536
directory_preview_limit = 80
# wrap_text = true
# max_preview_bytes = 131072
[behavior]
confirm_delete = true
confirm_overwrite = true
calculate_dir_size_on_space = true
follow_symlinks = false