TUISTREAM/internal/tui/file_browser.go
28allday 959e507a83 Initial commit: TUISTREAM — headless Jellyfin TUI for Omarchy/Arch
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>
2026-05-31 16:47:18 +01:00

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
}