Initial vcom TUI prototype
This commit is contained in:
commit
059f925e00
16 changed files with 3227 additions and 0 deletions
0
.codex
Normal file
0
.codex
Normal file
90
README.md
Normal file
90
README.md
Normal 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
35
cmd/vcom/main.go
Normal 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
196
docs/architecture.md
Normal 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
31
go.mod
Normal 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
49
go.sum
Normal 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
215
internal/config/config.go
Normal 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
95
internal/fs/entry.go
Normal 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
183
internal/fs/ops.go
Normal 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
204
internal/fs/preview.go
Normal 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
268
internal/fs/scan.go
Normal 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
157
internal/theme/theme.go
Normal 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
67
internal/ui/keymap.go
Normal 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
1115
internal/ui/model.go
Normal file
File diff suppressed because it is too large
Load diff
472
internal/ui/pane.go
Normal file
472
internal/ui/pane.go
Normal 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
50
vcom.toml
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue