From 059f925e007e7961477031d2bdfddd01913e553d Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Wed, 22 Apr 2026 22:10:50 +0300 Subject: [PATCH] Initial vcom TUI prototype --- .codex | 0 README.md | 90 +++ cmd/vcom/main.go | 35 ++ docs/architecture.md | 196 +++++++ go.mod | 31 ++ go.sum | 49 ++ internal/config/config.go | 215 +++++++ internal/fs/entry.go | 95 ++++ internal/fs/ops.go | 183 ++++++ internal/fs/preview.go | 204 +++++++ internal/fs/scan.go | 268 +++++++++ internal/theme/theme.go | 157 ++++++ internal/ui/keymap.go | 67 +++ internal/ui/model.go | 1115 +++++++++++++++++++++++++++++++++++++ internal/ui/pane.go | 472 ++++++++++++++++ vcom.toml | 50 ++ 16 files changed, 3227 insertions(+) create mode 100644 .codex create mode 100644 README.md create mode 100644 cmd/vcom/main.go create mode 100644 docs/architecture.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/fs/entry.go create mode 100644 internal/fs/ops.go create mode 100644 internal/fs/preview.go create mode 100644 internal/fs/scan.go create mode 100644 internal/theme/theme.go create mode 100644 internal/ui/keymap.go create mode 100644 internal/ui/model.go create mode 100644 internal/ui/pane.go create mode 100644 vcom.toml diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md new file mode 100644 index 0000000..130d700 --- /dev/null +++ b/README.md @@ -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). diff --git a/cmd/vcom/main.go b/cmd/vcom/main.go new file mode 100644 index 0000000..bd9f529 --- /dev/null +++ b/cmd/vcom/main.go @@ -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) + } +} diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..9337a5e --- /dev/null +++ b/docs/architecture.md @@ -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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..72a8f66 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..bb04a3e --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..4ca2b2a --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/fs/entry.go b/internal/fs/entry.go new file mode 100644 index 0000000..6e1d0eb --- /dev/null +++ b/internal/fs/entry.go @@ -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 +} diff --git a/internal/fs/ops.go b/internal/fs/ops.go new file mode 100644 index 0000000..e71590d --- /dev/null +++ b/internal/fs/ops.go @@ -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 +} diff --git a/internal/fs/preview.go b/internal/fs/preview.go new file mode 100644 index 0000000..9bc062c --- /dev/null +++ b/internal/fs/preview.go @@ -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), ".") + } +} diff --git a/internal/fs/scan.go b/internal/fs/scan.go new file mode 100644 index 0000000..ec4b526 --- /dev/null +++ b/internal/fs/scan.go @@ -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) +} diff --git a/internal/theme/theme.go b/internal/theme/theme.go new file mode 100644 index 0000000..49cfb11 --- /dev/null +++ b/internal/theme/theme.go @@ -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) + } +} diff --git a/internal/ui/keymap.go b/internal/ui/keymap.go new file mode 100644 index 0000000..510365b --- /dev/null +++ b/internal/ui/keymap.go @@ -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}, + } +} diff --git a/internal/ui/model.go b/internal/ui/model.go new file mode 100644 index 0000000..fe7f2ed --- /dev/null +++ b/internal/ui/model.go @@ -0,0 +1,1115 @@ +package ui + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "vcom/internal/config" + vfs "vcom/internal/fs" + "vcom/internal/theme" +) + +type modalKind int + +const ( + modalNone modalKind = iota + modalMkdir + modalConfirm +) + +type fileOpKind int + +const ( + opCopy fileOpKind = iota + opMove + opDelete + opMkdir + opEdit + opView +) + +type pendingOperation struct { + kind fileOpKind + sourcePath string + targetDir string + overwrite bool +} + +type modalState struct { + kind modalKind + title string + body string + note string + input textinput.Model + pending *pendingOperation +} + +type previewMsg struct { + entryPath string + preview vfs.Preview +} + +type dirSizeMsg struct { + path string + size int64 + err error +} + +type opMsg struct { + kind fileOpKind + sourcePath string + targetPath string + err error +} + +type Model struct { + cfg config.Config + configPath string + palette theme.Palette + keys KeyMap + + width int + height int + + left BrowserPane + right BrowserPane + active PaneID + infoMode bool + + helpModel help.Model + previewModel viewport.Model + previewData vfs.Preview + + modal modalState + status string + busy bool +} + +func NewModel(cfg config.Config, configPath string) (Model, error) { + palette, err := theme.Resolve(cfg.UI.Theme) + if err != nil { + return Model{}, err + } + + cwd, err := os.Getwd() + if err != nil { + return Model{}, err + } + + leftPath, err := resolveStartPath(cfg.Startup.LeftPath, cwd) + if err != nil { + return Model{}, err + } + rightPath, err := resolveStartPath(cfg.Startup.RightPath, cwd) + if err != nil { + return Model{}, err + } + + model := Model{ + cfg: cfg, + configPath: configPath, + palette: palette, + keys: DefaultKeyMap(), + left: BrowserPane{ID: PaneLeft, Path: leftPath}, + right: BrowserPane{ID: PaneRight, Path: rightPath}, + active: PaneLeft, + status: "Ready", + } + + model.helpModel = help.New() + model.helpModel.ShowAll = false + model.helpModel.Styles = help.Styles{ + ShortKey: lipgloss.NewStyle().Foreground(palette.FooterKey).Bold(true), + ShortDesc: lipgloss.NewStyle().Foreground(palette.Text), + ShortSeparator: lipgloss.NewStyle().Foreground(palette.Border), + Ellipsis: lipgloss.NewStyle().Foreground(palette.Muted), + FullKey: lipgloss.NewStyle().Foreground(palette.FooterKey).Bold(true), + FullDesc: lipgloss.NewStyle().Foreground(palette.Text), + FullSeparator: lipgloss.NewStyle().Foreground(palette.Border), + } + model.previewModel = viewport.New(0, 0) + if err := model.reloadPane(PaneLeft, ""); err != nil { + return Model{}, err + } + if err := model.reloadPane(PaneRight, ""); err != nil { + return Model{}, err + } + return model, nil +} + +func (m Model) Init() tea.Cmd { + return m.loadPreviewCmd() +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.resizePreview() + m.syncPreviewContent() + return m, nil + + case previewMsg: + if selected, ok := m.activePane().Selected(); ok && selected.Path == msg.entryPath { + m.applyPreview(msg.preview) + } + return m, nil + + case dirSizeMsg: + m.busy = false + if msg.err != nil { + m.status = fmt.Sprintf("Dir size failed: %v", msg.err) + return m, nil + } + m.applyDirSize(msg.path, msg.size) + m.status = fmt.Sprintf("Directory size calculated: %s", vfs.HumanSize(msg.size)) + return m, m.loadPreviewCmd() + + case opMsg: + m.busy = false + if msg.err != nil { + m.status = msg.err.Error() + return m, nil + } + + m.modal = modalState{} + switch msg.kind { + case opCopy: + m.status = fmt.Sprintf("Copied to %s", msg.targetPath) + case opMove: + m.status = fmt.Sprintf("Moved to %s", msg.targetPath) + case opDelete: + m.status = "Deleted" + case opMkdir: + m.status = fmt.Sprintf("Created %s", msg.targetPath) + case opEdit: + m.status = "Editor closed" + return m, m.loadPreviewCmd() + case opView: + m.status = "Viewer closed" + return m, nil + } + + activeSelection := selectedName(m.activePane()) + _ = m.reloadPane(PaneLeft, activeSelection) + _ = m.reloadPane(PaneRight, activeSelection) + return m, m.loadPreviewCmd() + + case tea.KeyMsg: + if m.modal.kind != modalNone { + return m.handleModalKey(msg) + } + + switch { + case key.Matches(msg, m.keys.Quit): + return m, tea.Quit + case key.Matches(msg, m.keys.View): + return m.handleView() + case key.Matches(msg, m.keys.Edit): + return m.handleEdit() + case key.Matches(msg, m.keys.Info): + return m.toggleInfo() + case key.Matches(msg, m.keys.ToggleHidden): + return m.toggleHidden() + case key.Matches(msg, m.keys.CycleTheme): + return m.cycleTheme() + case key.Matches(msg, m.keys.CycleSort): + return m.cycleSort() + case key.Matches(msg, m.keys.Switch): + if m.active == PaneLeft { + m.active = PaneRight + } else { + m.active = PaneLeft + } + m.status = fmt.Sprintf("Active pane: %s", strings.ToUpper(string(m.active))) + return m, m.loadPreviewCmd() + case key.Matches(msg, m.keys.Up): + m.moveCursor(-1) + return m, m.loadPreviewCmd() + case key.Matches(msg, m.keys.Down): + m.moveCursor(1) + return m, m.loadPreviewCmd() + case key.Matches(msg, m.keys.PageUp): + m.moveCursor(-max(m.bodyHeight()-6, 5)) + return m, m.loadPreviewCmd() + case key.Matches(msg, m.keys.PageDown): + m.moveCursor(max(m.bodyHeight()-6, 5)) + return m, m.loadPreviewCmd() + case key.Matches(msg, m.keys.Open): + if err := m.enterSelected(); err != nil { + m.status = err.Error() + return m, nil + } + return m, m.loadPreviewCmd() + case key.Matches(msg, m.keys.Back): + if err := m.goParent(); err != nil { + m.status = err.Error() + } + return m, m.loadPreviewCmd() + case key.Matches(msg, m.keys.Refresh): + return m.refreshAllPanes("Refreshed") + case key.Matches(msg, m.keys.DirSize): + return m.handleDirSize() + case key.Matches(msg, m.keys.Copy): + return m.handleTransfer(opCopy) + case key.Matches(msg, m.keys.Move): + return m.handleTransfer(opMove) + case key.Matches(msg, m.keys.Mkdir): + m.openMkdirModal() + return m, nil + case key.Matches(msg, m.keys.Delete): + return m.handleDelete() + } + } + + return m, nil +} + +func (m Model) View() string { + if m.width < 72 || m.height < 18 { + return lipgloss.NewStyle(). + Foreground(m.palette.Warning). + Padding(1, 2). + Render("Terminal is too small for vcom. Resize the window.") + } + + leftWidth, previewWidth, rightWidth := m.layoutWidths() + bodyHeight := m.bodyHeight() + gap := strings.Repeat(" ", m.cfg.UI.PaneGap) + + var panels string + if m.infoMode { + if m.active == PaneLeft { + panels = lipgloss.JoinHorizontal( + lipgloss.Top, + renderPane(m.left, m.cfg, m.palette, leftWidth, bodyHeight, true), + gap, + renderPreviewPane(m.previewData, &m.previewModel, m.cfg, m.palette, previewWidth, bodyHeight), + ) + } else { + panels = lipgloss.JoinHorizontal( + lipgloss.Top, + renderPreviewPane(m.previewData, &m.previewModel, m.cfg, m.palette, previewWidth, bodyHeight), + gap, + renderPane(m.right, m.cfg, m.palette, rightWidth, bodyHeight, true), + ) + } + } else { + panels = lipgloss.JoinHorizontal( + lipgloss.Top, + renderPane(m.left, m.cfg, m.palette, leftWidth, bodyHeight, m.active == PaneLeft), + gap, + renderPane(m.right, m.cfg, m.palette, rightWidth, bodyHeight, m.active == PaneRight), + ) + } + + parts := make([]string, 0, 4) + if m.cfg.UI.ShowTitleBar { + parts = append(parts, renderTitleBar(m)) + } + parts = append(parts, panels) + parts = append(parts, renderStatus(m)) + if m.cfg.UI.ShowFooter { + parts = append(parts, renderFooter(m)) + } + + view := lipgloss.JoinVertical(lipgloss.Left, parts...) + if m.modal.kind != modalNone { + view = overlayCenter(view, renderModal(m.modal, m.palette, min(64, m.width-8)), m.width) + } + return view +} + +func (m Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch m.modal.kind { + case modalMkdir: + switch { + case key.Matches(msg, m.keys.Cancel): + m.modal = modalState{} + m.status = "Cancelled" + return m, nil + case key.Matches(msg, m.keys.Confirm): + value := strings.TrimSpace(m.modal.input.Value()) + if value == "" { + m.status = "Directory name must not be empty" + return m, nil + } + m.busy = true + return m, mkdirCmd(m.activePane().Path, value) + } + + var cmd tea.Cmd + m.modal.input, cmd = m.modal.input.Update(msg) + return m, cmd + + case modalConfirm: + switch { + case key.Matches(msg, m.keys.Cancel): + m.modal = modalState{} + m.status = "Cancelled" + return m, nil + case key.Matches(msg, m.keys.Confirm): + if m.modal.pending == nil { + m.modal = modalState{} + m.status = "Nothing to confirm" + return m, nil + } + m.busy = true + cmd := m.modal.pending.cmd() + m.modal = modalState{} + return m, cmd + } + } + + return m, nil +} + +func (m *Model) reloadPane(id PaneID, preserve string) error { + pane := m.paneByID(id) + entries, err := vfs.ListDir(pane.Path, vfs.ListOptions{ + ShowHidden: m.cfg.Browser.ShowHidden, + DirsFirst: m.cfg.Browser.DirsFirst, + SortBy: m.cfg.Browser.Sort.By, + SortReverse: m.cfg.Browser.Sort.Reverse, + }) + if err != nil { + return err + } + pane.SetEntries(entries, strings.ToLower(preserve)) + return nil +} + +func (m *Model) refreshAllPanes(status string) (tea.Model, tea.Cmd) { + leftSelected := selectedName(&m.left) + rightSelected := selectedName(&m.right) + if err := m.reloadPane(PaneLeft, leftSelected); err != nil { + m.status = err.Error() + return m, nil + } + if err := m.reloadPane(PaneRight, rightSelected); err != nil { + m.status = err.Error() + return m, nil + } + m.status = status + return m, m.loadPreviewCmd() +} + +func (m *Model) moveCursor(delta int) { + pane := m.activePane() + pane.Move(delta, max(m.bodyHeight()-4, 1)) +} + +func (m *Model) enterSelected() error { + pane := m.activePane() + selected, ok := pane.Selected() + if !ok { + return nil + } + if !selected.IsDir { + m.status = "File is shown in the middle pane. Use F3 for pager or F4 for editor." + return nil + } + currentName := selected.Name + pane.Path = selected.Path + if err := m.reloadPane(pane.ID, currentName); err != nil { + return err + } + m.status = fmt.Sprintf("Entered %s", pane.Path) + return nil +} + +func (m *Model) goParent() error { + pane := m.activePane() + parent := filepath.Dir(pane.Path) + if parent == pane.Path { + return nil + } + currentName := filepath.Base(pane.Path) + pane.Path = parent + if err := m.reloadPane(pane.ID, currentName); err != nil { + return err + } + m.status = fmt.Sprintf("Moved to %s", parent) + return nil +} + +func (m Model) loadPreviewCmd() tea.Cmd { + selected, ok := m.activePane().Selected() + if !ok { + return func() tea.Msg { + return previewMsg{ + entryPath: "", + preview: vfs.Preview{ + Kind: vfs.PreviewKindEmpty, + Title: "Nothing selected", + Body: "No entry selected.", + }, + } + } + } + + options := vfs.PreviewOptions{ + ShowHidden: m.cfg.Browser.ShowHidden, + DirsFirst: m.cfg.Browser.DirsFirst, + SortBy: m.cfg.Browser.Sort.By, + SortReverse: m.cfg.Browser.Sort.Reverse, + MaxPreviewBytes: m.cfg.Preview.MaxPreviewBytes, + DirectoryPreviewLimit: m.cfg.Preview.DirectoryPreviewLimit, + HumanReadableSize: m.cfg.Browser.HumanReadableSize, + } + + return func() tea.Msg { + return previewMsg{ + entryPath: selected.Path, + preview: vfs.BuildPreview(selected, options), + } + } +} + +func (m *Model) handleDirSize() (tea.Model, tea.Cmd) { + if !m.cfg.Behavior.CalculateDirSizeOnSpace { + m.status = "Directory size on Space is disabled in config" + return m, nil + } + selected, ok := m.activePane().Selected() + if !ok || !selected.IsDir || selected.IsParent { + m.status = "Select a directory first" + return m, nil + } + if selected.DirSizeKnown { + m.status = fmt.Sprintf("Directory size: %s", formatSize(selected.Size, m.cfg.Browser.HumanReadableSize)) + return m, nil + } + + m.busy = true + m.status = fmt.Sprintf("Calculating directory size for %s", selected.DisplayName()) + return m, dirSizeCmd(selected.Path) +} + +func (m *Model) handleTransfer(kind fileOpKind) (tea.Model, tea.Cmd) { + selected, ok := m.activePane().Selected() + if !ok || selected.IsParent { + m.status = fmt.Sprintf("Nothing to %s", operationVerb(kind)) + return m, nil + } + + targetDir := m.passivePane().Path + targetPath := filepath.Join(targetDir, filepath.Base(selected.Path)) + exists, err := vfs.PathExists(targetPath) + if err != nil { + m.status = err.Error() + return m, nil + } + + if exists && m.cfg.Behavior.ConfirmOverwrite { + title := fmt.Sprintf("Overwrite existing target before %s?", operationVerb(kind)) + body := fmt.Sprintf("%s\n\n-> %s", selected.Path, targetPath) + note := "Enter/y to overwrite, Esc/n to cancel" + m.openConfirmModal(title, body, note, pendingOperation{ + kind: kind, + sourcePath: selected.Path, + targetDir: targetDir, + overwrite: true, + }) + return m, nil + } + + m.busy = true + m.status = fmt.Sprintf("%s %s", strings.Title(operationVerb(kind)), selected.DisplayName()) + return m, operationCmd(kind, selected.Path, targetDir, exists) +} + +func (m *Model) handleDelete() (tea.Model, tea.Cmd) { + selected, ok := m.activePane().Selected() + if !ok || selected.IsParent { + m.status = "Nothing to delete" + return m, nil + } + if !m.cfg.Behavior.ConfirmDelete { + m.busy = true + m.status = fmt.Sprintf("Deleting %s", selected.DisplayName()) + return m, deleteCmd(selected.Path) + } + + m.openConfirmModal( + "Delete selected entry?", + selected.Path, + "Enter/y to delete permanently, Esc/n to cancel", + pendingOperation{ + kind: opDelete, + sourcePath: selected.Path, + }, + ) + return m, nil +} + +func (m *Model) handleView() (tea.Model, tea.Cmd) { + selected, ok := m.activePane().Selected() + if !ok || selected.IsParent || selected.IsDir { + m.status = "Preview refreshed" + return m, m.loadPreviewCmd() + } + + command, name, err := externalCommand("PAGER", []string{"less", "more"}, selected.Path) + if err != nil { + m.status = "Preview refreshed in center pane" + return m, m.loadPreviewCmd() + } + + m.status = fmt.Sprintf("Opening %s with %s", selected.DisplayName(), name) + return m, tea.ExecProcess(command, func(err error) tea.Msg { + return opMsg{kind: opView, sourcePath: selected.Path, err: err} + }) +} + +func (m *Model) handleEdit() (tea.Model, tea.Cmd) { + selected, ok := m.activePane().Selected() + if !ok || selected.IsParent || selected.IsDir { + m.status = "Select a file to edit" + return m, nil + } + + command, name, err := externalCommand("EDITOR", []string{"nvim", "vim", "vi", "nano"}, selected.Path) + if err != nil { + m.status = "Set $EDITOR or install nvim/vim/vi/nano to enable F4 editing" + return m, nil + } + + m.status = fmt.Sprintf("Opening %s with %s", selected.DisplayName(), name) + return m, tea.ExecProcess(command, func(err error) tea.Msg { + return opMsg{kind: opEdit, sourcePath: selected.Path, err: err} + }) +} + +func (m *Model) toggleInfo() (tea.Model, tea.Cmd) { + m.infoMode = !m.infoMode + m.resizePreview() + m.syncPreviewContent() + if m.infoMode { + m.status = fmt.Sprintf("Info mode: %s selection", strings.ToUpper(string(m.active))) + return m, m.loadPreviewCmd() + } + m.status = "Info mode: off" + return m, nil +} + +func (m *Model) toggleHidden() (tea.Model, tea.Cmd) { + m.cfg.Browser.ShowHidden = !m.cfg.Browser.ShowHidden + return m.refreshAllPanes(fmt.Sprintf("Show hidden: %t", m.cfg.Browser.ShowHidden)) +} + +func (m *Model) cycleTheme() (tea.Model, tea.Cmd) { + next := theme.Next(m.cfg.UI.Theme) + palette, err := theme.Resolve(next) + if err != nil { + m.status = err.Error() + return m, nil + } + m.cfg.UI.Theme = next + m.palette = palette + m.status = fmt.Sprintf("Theme: %s", next) + return m, nil +} + +func (m *Model) cycleSort() (tea.Model, tea.Cmd) { + order := []string{"name", "modified", "size", "created", "extension"} + current := strings.ToLower(strings.TrimSpace(m.cfg.Browser.Sort.By)) + next := order[0] + for idx, value := range order { + if value == current { + next = order[(idx+1)%len(order)] + break + } + } + m.cfg.Browser.Sort.By = next + return m.refreshAllPanes(fmt.Sprintf("Sort: %s", next)) +} + +func (m *Model) openMkdirModal() { + input := textinput.New() + input.Placeholder = "new-directory" + input.Focus() + input.CharLimit = 128 + input.Width = 42 + + m.modal = modalState{ + kind: modalMkdir, + title: "Create directory", + body: fmt.Sprintf("Active pane: %s", m.activePane().Path), + note: "Enter to confirm, Esc to cancel", + input: input, + } +} + +func (m *Model) openConfirmModal(title, body, note string, pending pendingOperation) { + m.modal = modalState{ + kind: modalConfirm, + title: title, + body: body, + note: note, + pending: &pending, + } +} + +func (m *Model) applyDirSize(path string, size int64) { + for _, pane := range []*BrowserPane{&m.left, &m.right} { + for idx := range pane.Entries { + if pane.Entries[idx].Path == path { + pane.Entries[idx].Size = size + pane.Entries[idx].DirSizeKnown = true + } + } + } +} + +func (m *Model) applyPreview(preview vfs.Preview) { + m.previewData = preview + m.syncPreviewContent() +} + +func (m *Model) syncPreviewContent() { + content := m.previewData.Body + if m.cfg.Preview.WrapText && m.previewModel.Width > 0 { + content = lipgloss.NewStyle().Width(m.previewModel.Width).Render(content) + } + m.previewModel.SetContent(content) +} + +func (m *Model) activePane() *BrowserPane { + if m.active == PaneLeft { + return &m.left + } + return &m.right +} + +func (m *Model) passivePane() *BrowserPane { + if m.active == PaneLeft { + return &m.right + } + return &m.left +} + +func (m *Model) paneByID(id PaneID) *BrowserPane { + if id == PaneLeft { + return &m.left + } + return &m.right +} + +func (m *Model) layoutWidths() (int, int, int) { + total := m.width + gaps := m.cfg.UI.PaneGap + usable := max(total-gaps, 60) + left := usable / 2 + right := usable - left + + if m.active == PaneLeft { + if m.infoMode { + return left, right, 0 + } + return left, 0, right + } + if m.infoMode { + return 0, left, right + } + return left, 0, right +} + +func (m *Model) bodyHeight() int { + height := m.height - 1 + if m.cfg.UI.ShowTitleBar { + height-- + } + if m.cfg.UI.ShowFooter { + height-- + } + return max(height, 8) +} + +func (m *Model) resizePreview() { + _, previewWidth, _ := m.layoutWidths() + metaHeight := 0 + if m.cfg.Preview.ShowMetadata { + metaHeight = 6 + } + m.previewModel.Width = max(previewWidth-4, 10) + m.previewModel.Height = max(m.bodyHeight()-metaHeight-4, 3) +} + +func renderPreviewPane(preview vfs.Preview, viewportModel *viewport.Model, cfg config.Config, palette theme.Palette, width int, height int) string { + box := lipgloss.NewStyle(). + Width(width). + Height(height). + Background(palette.Panel). + Foreground(palette.Text). + BorderStyle(borderStyle(cfg.UI.Border)). + BorderForeground(palette.BorderActive) + + title := lipgloss.NewStyle(). + Width(width-2). + Padding(0, 1). + Background(palette.Accent). + Foreground(palette.Background). + Bold(true). + Render("PREVIEW " + previewIcon(preview) + " " + preview.Title) + + parts := []string{title} + if cfg.Preview.ShowMetadata { + parts = append(parts, renderMetadata(preview.Metadata, palette, width-2)) + } + parts = append(parts, renderPreviewContent(viewportModel, palette, width-2)) + + return box.Render(lipgloss.JoinVertical(lipgloss.Left, parts...)) +} + +func renderMetadata(meta vfs.Metadata, palette theme.Palette, width int) string { + rows := []string{ + fmt.Sprintf("kind: %s", fallback(meta.Kind, "n/a")), + fmt.Sprintf("size: %s", metaSize(meta)), + fmt.Sprintf("created: %s", fallback(meta.CreatedAt, "n/a")), + fmt.Sprintf("modified: %s", fallback(meta.ModifiedAt, "n/a")), + fmt.Sprintf("mode: %s", fallback(meta.Permissions, "n/a")), + } + if meta.ImageFormat != "" { + rows = append(rows, fmt.Sprintf("image: %s %s", meta.ImageFormat, meta.ImageSize)) + } + rows = append(rows, fmt.Sprintf("path: %s", truncateMiddle(meta.Path, max(width-10, 12)))) + + leftWidth := max(width/2, 18) + left := lipgloss.NewStyle(). + Width(leftWidth). + Foreground(palette.Muted). + Render(strings.Join(rows[:min(3, len(rows))], "\n")) + right := lipgloss.NewStyle(). + Width(width - leftWidth). + Foreground(palette.Text). + Render(strings.Join(rows[min(3, len(rows)):], "\n")) + + return lipgloss.NewStyle(). + Width(width). + Padding(0, 1). + Background(palette.PanelElevated). + BorderStyle(lipgloss.NormalBorder()). + BorderBottom(true). + BorderForeground(palette.Border). + Render(lipgloss.JoinHorizontal(lipgloss.Top, left, right)) +} + +func renderTitleBar(m Model) string { + left := lipgloss.NewStyle(). + Foreground(m.palette.Background). + Background(m.palette.Accent). + Bold(true). + Padding(0, 1). + Render(strings.ToUpper(m.cfg.UI.AppTitle)) + + centerParts := []string{ + fmt.Sprintf("theme:%s", m.cfg.UI.Theme), + fmt.Sprintf("hidden:%t", m.cfg.Browser.ShowHidden), + fmt.Sprintf("sort:%s", m.cfg.Browser.Sort.By), + fmt.Sprintf("info:%t", m.infoMode), + } + center := lipgloss.NewStyle(). + Foreground(m.palette.Text). + Background(m.palette.Panel). + Padding(0, 1). + Render(strings.Join(centerParts, " ")) + + configLabel := "cfg:default" + if m.configPath != "" { + configLabel = "cfg:" + filepath.Base(m.configPath) + } + right := lipgloss.NewStyle(). + Foreground(m.palette.Muted). + Background(m.palette.Panel). + Padding(0, 1). + Render(configLabel) + + fillWidth := max(m.width-lipgloss.Width(left)-lipgloss.Width(center)-lipgloss.Width(right), 0) + fill := lipgloss.NewStyle(). + Width(fillWidth). + Background(m.palette.Panel). + Render("") + + return left + center + fill + right +} + +func renderStatus(m Model) string { + active := m.activePane() + selected, _ := active.Selected() + summary := fmt.Sprintf( + "%s | %s | items:%d | selected:%s", + strings.ToUpper(string(m.active)), + compactPath(active.Path, m.cfg.UI.PathDisplay), + len(active.Entries), + fallback(selected.DisplayName(), "n/a"), + ) + + return lipgloss.NewStyle(). + Width(m.width). + Padding(0, 1). + Background(m.palette.PanelInactive). + Foreground(m.palette.Text). + Render(summary + " :: " + m.status) +} + +func renderFooter(m Model) string { + helpModel := m.helpModel + helpModel.Width = max(m.width-28, 20) + helpView := helpModel.View(m.keys) + legend := lipgloss.NewStyle(). + Foreground(m.palette.Muted). + Render(" dir 󰈙 text  config 󰆍 exec 󰋩 image 󰈔 bin") + return lipgloss.NewStyle(). + Width(m.width). + Padding(0, 1). + Background(m.palette.Panel). + Render(lipgloss.JoinHorizontal(lipgloss.Top, helpView, " ", legend)) +} + +func renderModal(modal modalState, palette theme.Palette, width int) string { + box := lipgloss.NewStyle(). + Width(width). + Padding(1, 2). + Background(palette.Panel). + Foreground(palette.Text). + BorderStyle(lipgloss.DoubleBorder()). + BorderForeground(palette.BorderActive) + + lines := []string{ + lipgloss.NewStyle().Bold(true).Foreground(palette.Accent).Render(modal.title), + lipgloss.NewStyle().Foreground(palette.Muted).Render(modal.body), + } + + if modal.kind == modalMkdir { + lines = append(lines, modal.input.View()) + } + if modal.note != "" { + lines = append(lines, lipgloss.NewStyle().Foreground(palette.Muted).Render(modal.note)) + } + + return box.Render(strings.Join(lines, "\n\n")) +} + +func overlayCenter(base string, overlay string, width int) string { + if width <= 0 { + return base + "\n" + overlay + } + centered := lipgloss.Place(width, lipgloss.Height(overlay), lipgloss.Center, lipgloss.Top, overlay) + return base + "\n" + centered +} + +func renderPreviewContent(viewportModel *viewport.Model, palette theme.Palette, width int) string { + header := lipgloss.NewStyle(). + Width(width). + Padding(0, 1). + Background(palette.PanelInactive). + Foreground(palette.FooterKey). + Bold(true). + Render("CONTENT") + + body := lipgloss.NewStyle(). + Width(width). + Padding(0, 1). + Background(palette.Panel). + Render(viewportModel.View()) + + return lipgloss.NewStyle(). + Width(width). + BorderStyle(lipgloss.NormalBorder()). + BorderTop(true). + BorderForeground(palette.Border). + Render(lipgloss.JoinVertical(lipgloss.Left, header, body)) +} + +func previewIcon(preview vfs.Preview) string { + switch preview.Kind { + case vfs.PreviewKindDirectory: + return "" + case vfs.PreviewKindImage: + return "󰋩" + case vfs.PreviewKindText: + return "󰈙" + case vfs.PreviewKindBinary: + return "󰈔" + case vfs.PreviewKindError: + return "" + default: + return "󰇙" + } +} + +func (p pendingOperation) cmd() tea.Cmd { + switch p.kind { + case opCopy: + return copyCmd(p.sourcePath, p.targetDir, p.overwrite) + case opMove: + return moveCmd(p.sourcePath, p.targetDir, p.overwrite) + case opDelete: + return deleteCmd(p.sourcePath) + default: + return nil + } +} + +func operationCmd(kind fileOpKind, sourcePath, targetDir string, overwrite bool) tea.Cmd { + switch kind { + case opCopy: + return copyCmd(sourcePath, targetDir, overwrite) + case opMove: + return moveCmd(sourcePath, targetDir, overwrite) + default: + return nil + } +} + +func dirSizeCmd(path string) tea.Cmd { + return func() tea.Msg { + size, err := vfs.DirectorySize(path) + return dirSizeMsg{path: path, size: size, err: err} + } +} + +func copyCmd(sourcePath, targetDir string, overwrite bool) tea.Cmd { + return func() tea.Msg { + targetPath, err := vfs.CopyPath(sourcePath, targetDir, overwrite) + return opMsg{kind: opCopy, sourcePath: sourcePath, targetPath: targetPath, err: err} + } +} + +func moveCmd(sourcePath, targetDir string, overwrite bool) tea.Cmd { + return func() tea.Msg { + targetPath, err := vfs.MovePath(sourcePath, targetDir, overwrite) + return opMsg{kind: opMove, sourcePath: sourcePath, targetPath: targetPath, err: err} + } +} + +func deleteCmd(path string) tea.Cmd { + return func() tea.Msg { + err := vfs.DeletePath(path) + return opMsg{kind: opDelete, sourcePath: path, err: err} + } +} + +func mkdirCmd(parent, name string) tea.Cmd { + return func() tea.Msg { + targetPath, err := vfs.MakeDir(parent, name) + return opMsg{kind: opMkdir, targetPath: targetPath, err: err} + } +} + +func selectedName(pane *BrowserPane) string { + selected, ok := pane.Selected() + if !ok { + return "" + } + return selected.Name +} + +func metaSize(meta vfs.Metadata) string { + if !meta.SizeKnown { + return "press Space" + } + return vfs.HumanSize(meta.Size) +} + +func fallback(value string, defaultValue string) string { + if strings.TrimSpace(value) == "" { + return defaultValue + } + return value +} + +func formatSize(size int64, human bool) string { + if human { + return vfs.HumanSize(size) + } + return fmt.Sprintf("%d", size) +} + +func operationVerb(kind fileOpKind) string { + switch kind { + case opCopy: + return "copy" + case opMove: + return "move" + case opDelete: + return "delete" + default: + return "operate on" + } +} + +func externalCommand(envVar string, fallbacks []string, path string) (*exec.Cmd, string, error) { + commandLine := strings.TrimSpace(os.Getenv(envVar)) + if commandLine == "" { + for _, candidate := range fallbacks { + if resolved, err := exec.LookPath(candidate); err == nil { + commandLine = resolved + break + } + } + } + if commandLine == "" { + return nil, "", fmt.Errorf("no command for %s", envVar) + } + + parts := strings.Fields(commandLine) + if len(parts) == 0 { + return nil, "", fmt.Errorf("invalid command for %s", envVar) + } + + args := append(parts[1:], path) + return exec.Command(parts[0], args...), filepath.Base(parts[0]), nil +} + +func resolveStartPath(raw string, fallback string) (string, error) { + value := strings.TrimSpace(raw) + if value == "" { + return fallback, nil + } + if strings.HasPrefix(value, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + value = filepath.Join(home, strings.TrimPrefix(value, "~/")) + } + abs, err := filepath.Abs(value) + if err != nil { + return "", err + } + info, err := os.Stat(abs) + if err != nil { + return "", err + } + if !info.IsDir() { + return "", fmt.Errorf("startup path is not a directory: %s", abs) + } + return abs, nil +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/internal/ui/pane.go b/internal/ui/pane.go new file mode 100644 index 0000000..1de9b67 --- /dev/null +++ b/internal/ui/pane.go @@ -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 + } +} diff --git a/vcom.toml b/vcom.toml new file mode 100644 index 0000000..21812bb --- /dev/null +++ b/vcom.toml @@ -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