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