Three-tab Bubble Tea TUI (Setup / Manage / Monitor) for setting up and running a headless Jellyfin server over SSH: - Setup: install/uninstall Jellyfin, add media drives (single or btrfs RAID pool), import an existing detached btrfs pool non-destructively, firewall, move Jellyfin storage onto a media drive - Boot-drive-safe drive classifier (walks LUKS/LVM/RAID to the physical disk; never offers a disk hosting /, /boot or swap) - Keep-existing-filesystem path previews on-disk content, skips starter folders when content exists, and grants Jellyfin recursive read access - Manage: copy from external drives, mount/eject, rename/delete - Monitor: per-core CPU, load/mem/uptime, btrfs error counters, SMART Single static binary; installs to /usr/local/bin so sudo can find it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
278 lines
7.2 KiB
Go
278 lines
7.2 KiB
Go
// File browser sub-model used by the Manage tab. Browses a directory tree
|
|
// anchored at a fixed root (a drive's mountpoint) and refuses to escape it.
|
|
//
|
|
// Key bindings:
|
|
//
|
|
// ↑/↓ move cursor
|
|
// enter dive into a folder; on a file or with "select-here" sentinel, select
|
|
// right same as enter, but always dives (no select-here behaviour)
|
|
// left / esc go up; at the root, esc cancels
|
|
// space (caller-defined) often used to select the current cwd directly
|
|
// g / G jump to top / bottom
|
|
// pgup / pgdn page
|
|
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
)
|
|
|
|
// browseResult is what update() reports back to the caller after each key.
|
|
type browseResult int
|
|
|
|
const (
|
|
browseStillBrowsing browseResult = iota
|
|
browseCancelled
|
|
browseSelected
|
|
)
|
|
|
|
// fileBrowser holds the browser's mutable state. Owned by a caller — e.g.
|
|
// the Manage tab embeds two of these (src + dst).
|
|
type fileBrowser struct {
|
|
root string // absolute path the browser is anchored at; cwd never escapes it.
|
|
cwd string // current directory being shown
|
|
rows []browseEntry
|
|
idx int
|
|
|
|
// Window for paging — recomputed on each render based on terminal height.
|
|
// We track these so PgUp/PgDn move sensibly.
|
|
viewportRows int
|
|
|
|
// Result fields, populated on browseSelected.
|
|
selectedPath string
|
|
}
|
|
|
|
type browseEntry struct {
|
|
name string
|
|
path string
|
|
isDir bool
|
|
size int64
|
|
}
|
|
|
|
// newFileBrowser opens `root` and lands the user in it.
|
|
func newFileBrowser(root string) (fileBrowser, error) {
|
|
fb := fileBrowser{root: root, cwd: root, viewportRows: 12}
|
|
if err := fb.load(); err != nil {
|
|
return fileBrowser{}, err
|
|
}
|
|
return fb, nil
|
|
}
|
|
|
|
// SelectedPath is the absolute path of the picked file/folder. Only valid
|
|
// after the browser has returned browseSelected.
|
|
func (fb *fileBrowser) SelectedPath() string { return fb.selectedPath }
|
|
|
|
func (fb *fileBrowser) load() error {
|
|
entries, err := os.ReadDir(fb.cwd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fb.rows = fb.rows[:0]
|
|
for _, e := range entries {
|
|
// Hide dotfiles by default — most media drives have a .Trash-1000 etc.
|
|
if strings.HasPrefix(e.Name(), ".") {
|
|
continue
|
|
}
|
|
info, err := e.Info()
|
|
size := int64(0)
|
|
isDir := e.IsDir()
|
|
if err == nil {
|
|
size = info.Size()
|
|
}
|
|
fb.rows = append(fb.rows, browseEntry{
|
|
name: e.Name(),
|
|
path: filepath.Join(fb.cwd, e.Name()),
|
|
isDir: isDir,
|
|
size: size,
|
|
})
|
|
}
|
|
sort.SliceStable(fb.rows, func(i, j int) bool {
|
|
// Directories first, then files, both case-insensitive alpha.
|
|
if fb.rows[i].isDir != fb.rows[j].isDir {
|
|
return fb.rows[i].isDir
|
|
}
|
|
return strings.ToLower(fb.rows[i].name) < strings.ToLower(fb.rows[j].name)
|
|
})
|
|
fb.idx = 0
|
|
return nil
|
|
}
|
|
|
|
// update routes a key to the browser. Returns the result + an optional Cmd.
|
|
//
|
|
// Tab / Shift-Tab are intentionally NOT handled here — the caller checks
|
|
// for them first and passes them to the root model so the user can switch
|
|
// tabs mid-browse without losing their place.
|
|
func (fb *fileBrowser) update(msg tea.KeyMsg) (browseResult, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "up", "k":
|
|
if fb.idx > 0 {
|
|
fb.idx--
|
|
}
|
|
case "down", "j":
|
|
if fb.idx < len(fb.rows)-1 {
|
|
fb.idx++
|
|
}
|
|
case "pgup":
|
|
fb.idx -= fb.viewportRows
|
|
if fb.idx < 0 {
|
|
fb.idx = 0
|
|
}
|
|
case "pgdown":
|
|
fb.idx += fb.viewportRows
|
|
if fb.idx > len(fb.rows)-1 {
|
|
fb.idx = len(fb.rows) - 1
|
|
}
|
|
case "home", "g":
|
|
fb.idx = 0
|
|
case "end", "G":
|
|
fb.idx = len(fb.rows) - 1
|
|
case "left", "h", "backspace":
|
|
// Step up out of cwd. At the root, this is a no-op (use esc to cancel).
|
|
if fb.cwd == fb.root {
|
|
return browseStillBrowsing, nil
|
|
}
|
|
fb.cwd = filepath.Dir(fb.cwd)
|
|
_ = fb.load()
|
|
case "esc":
|
|
return browseCancelled, nil
|
|
case "right", "l":
|
|
// Always dives if on a folder; on a file this is a no-op.
|
|
if len(fb.rows) == 0 {
|
|
return browseStillBrowsing, nil
|
|
}
|
|
cur := fb.rows[fb.idx]
|
|
if cur.isDir {
|
|
fb.cwd = cur.path
|
|
_ = fb.load()
|
|
}
|
|
case "enter":
|
|
if len(fb.rows) == 0 {
|
|
return browseStillBrowsing, nil
|
|
}
|
|
cur := fb.rows[fb.idx]
|
|
if cur.isDir {
|
|
fb.cwd = cur.path
|
|
_ = fb.load()
|
|
} else {
|
|
fb.selectedPath = cur.path
|
|
return browseSelected, nil
|
|
}
|
|
case "s", "S":
|
|
// Select the cursor row itself (works for folders too).
|
|
if len(fb.rows) == 0 {
|
|
return browseStillBrowsing, nil
|
|
}
|
|
fb.selectedPath = fb.rows[fb.idx].path
|
|
return browseSelected, nil
|
|
}
|
|
return browseStillBrowsing, nil
|
|
}
|
|
|
|
// view renders the browser as a fixed-size pane. Always reserves
|
|
// viewportRows rows of body space so the card doesn't shrink/grow as the
|
|
// user navigates between sparse and dense directories.
|
|
func (fb *fileBrowser) view(title, footerHint string) string {
|
|
const nameCol = 56 // name column width before the right-aligned size
|
|
|
|
var rows []string
|
|
rows = append(rows, titleStyle.Render(title))
|
|
rows = append(rows, "")
|
|
rows = append(rows, devNameStyle.Render(fb.cwd))
|
|
rows = append(rows, "")
|
|
|
|
// Compute the visible window so the cursor stays inside it.
|
|
start := 0
|
|
if fb.idx >= fb.viewportRows {
|
|
start = fb.idx - fb.viewportRows + 1
|
|
}
|
|
end := start + fb.viewportRows
|
|
if end > len(fb.rows) {
|
|
end = len(fb.rows)
|
|
}
|
|
|
|
bodyRows := 0
|
|
if len(fb.rows) == 0 {
|
|
rows = append(rows, " "+headerStyle.Render("(empty directory)"))
|
|
bodyRows = 1
|
|
} else {
|
|
for i := start; i < end; i++ {
|
|
row := fb.rows[i]
|
|
name := row.name
|
|
if row.isDir {
|
|
name = devNameStyle.Render(name + "/")
|
|
}
|
|
size := ""
|
|
if !row.isDir {
|
|
size = humanSize(row.size)
|
|
}
|
|
// Pad the name column so all sizes right-align.
|
|
plainName := row.name
|
|
if row.isDir {
|
|
plainName += "/"
|
|
}
|
|
padding := nameCol - len(plainName)
|
|
if padding < 1 {
|
|
padding = 1
|
|
}
|
|
body := name + strings.Repeat(" ", padding) +
|
|
headerStyle.Render(fmt.Sprintf("%6s", size))
|
|
rows = append(rows, markerLine(i == fb.idx, body))
|
|
bodyRows++
|
|
}
|
|
}
|
|
// Reserve unused viewport rows with blank space so the card height
|
|
// is constant.
|
|
for ; bodyRows < fb.viewportRows; bodyRows++ {
|
|
rows = append(rows, "")
|
|
}
|
|
|
|
// Footer: row counter + key hints, always on the same line for a
|
|
// stable card height.
|
|
counter := ""
|
|
if len(fb.rows) > 0 {
|
|
counter = fmt.Sprintf("%d of %d · ", fb.idx+1, len(fb.rows))
|
|
}
|
|
rows = append(rows, "")
|
|
rows = append(rows, footerStyle.Render(counter+footerHint))
|
|
// NOTE: deliberately NOT centeredCard — the row widths change as you
|
|
// navigate (filenames, cwd path), so centring would make the list jitter
|
|
// horizontally while scrolling. A file listing reads best left-aligned; the
|
|
// card itself is still centred on the page by the root View.
|
|
return cardStyle.Render(strings.Join(rows, "\n"))
|
|
}
|
|
|
|
// humanSize formats a byte count with the smallest sensible suffix.
|
|
func humanSize(n int64) string {
|
|
const (
|
|
kb = 1024
|
|
mb = 1024 * kb
|
|
gb = 1024 * mb
|
|
tb = 1024 * gb
|
|
)
|
|
switch {
|
|
case n >= tb:
|
|
return fmtSize(float64(n)/tb, "T")
|
|
case n >= gb:
|
|
return fmtSize(float64(n)/gb, "G")
|
|
case n >= mb:
|
|
return fmtSize(float64(n)/mb, "M")
|
|
case n >= kb:
|
|
return fmtSize(float64(n)/kb, "K")
|
|
}
|
|
return fmt.Sprintf("%dB", n)
|
|
}
|
|
|
|
// fmtSize: one decimal under 10, none above. "1.2G", "44M", "512K".
|
|
func fmtSize(v float64, suffix string) string {
|
|
if v < 10 {
|
|
s := fmt.Sprintf("%.1f", v)
|
|
s = strings.TrimSuffix(s, ".0")
|
|
return s + suffix
|
|
}
|
|
return fmt.Sprintf("%.0f", v) + suffix
|
|
}
|