TUISTREAM/internal/tui/manage_actions.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

880 lines
23 KiB
Go

// Manage-tab action flow: copy, delete, rename. All three drive a small
// state machine (manageStage) plus a shared file browser, then hand the
// final command off to the shared step runner on the root Model.
package tui
import (
"fmt"
"os"
"os/exec"
"os/user"
"path/filepath"
"strconv"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"tuistream/internal/drives"
"tuistream/internal/step"
)
// manageStage is the current sub-state of the Manage tab. manageIdle is the
// default list view; manageActionMenu is the post-`c`/`d`/`n` flow. The
// "running" + "done" splashes live on the root Model and are shared with
// Setup.
type manageStage int
const (
manageIdle manageStage = iota
// Copy flow
manageCopySrcDrive // pick source (external) drive — skipped if exactly 1
manageCopySrcBrowse // browse source for file/folder to copy
manageCopyDstDrive // pick destination (managed) drive — skipped if exactly 1
manageCopyDstBrowse // browse destination for the folder to copy INTO
manageCopyConfirm
// Delete flow
manageDeleteDrive // pick a managed drive — skipped if exactly 1
manageDeleteBrowse // browse managed drive to pick the path to delete
manageDeleteConfirm
// Rename flow
manageRenameDrive // pick a managed drive — skipped if exactly 1
manageRenameBrowse // browse managed drive to pick the path to rename
manageRenameInput // textinput for the new basename
manageRenameConfirm
// Mount / unmount flows (headless boxes, no auto-mount daemon)
manageMountPick
manageMountConfirm
manageUnmountPick
manageUnmountConfirm
)
// manageAction is the action the user picked. It just disambiguates the
// drive-picker + browse sub-stages, which would otherwise need 3x as many
// enum values.
type manageActionKind int
const (
manageActNone manageActionKind = iota
manageActCopy
manageActDelete
manageActRename
manageActMount
manageActUnmount
)
// manageAction carries the action-in-flight state. Populated incrementally
// as the user advances through the sub-stages.
type manageAction struct {
kind manageActionKind
// Drive picker state — used both for the source-drive and dest-drive
// stages of Copy, and for the single drive stage of Delete/Rename.
driveChoices []drives.Drive
driveIdx int
// Resolved drives.
srcDrive drives.Drive // copy source
dstDrive drives.Drive // copy destination, or the target of delete/rename
// File browser per side. For Copy we use two; for Delete/Rename only dst.
srcBrowse fileBrowser
dstBrowse fileBrowser
// Resolved paths.
srcPath string // absolute path on the source side (Copy)
dstPath string // absolute path on the destination side (Copy = folder; Delete/Rename = target itself)
// Rename-only: textinput for the new basename + confirmation index.
rename textinput.Model
confirmIdx int // 0 = Yes, 1 = No
// Mount-only state.
mountReadOnly bool // toggled with 'w' on the confirm screen; default true
mountPoint string // computed mountpoint for the chosen drive
}
// manageModel holds Manage-tab-specific state.
type manageModel struct {
stage manageStage
active manageAction
}
func newManageModel() manageModel { return manageModel{stage: manageIdle} }
// --- key handling ---
func handleManageKey(m Model, msg tea.KeyMsg) (Model, tea.Cmd, bool) {
key := msg.String()
switch m.manage.stage {
case manageIdle:
return manageIdleKey(m, key)
case manageCopySrcDrive:
return manageDrivePickerKey(m, key, manageCopySrcBrowse, manageActCopy, true)
case manageCopySrcBrowse:
return manageCopySrcBrowseKey(m, msg)
case manageCopyDstDrive:
return manageDrivePickerKey(m, key, manageCopyDstBrowse, manageActCopy, false)
case manageCopyDstBrowse:
return manageCopyDstBrowseKey(m, msg)
case manageCopyConfirm:
return manageCopyConfirmKey(m, key)
case manageDeleteDrive:
return manageDrivePickerKey(m, key, manageDeleteBrowse, manageActDelete, false)
case manageDeleteBrowse:
return manageDeleteBrowseKey(m, msg)
case manageDeleteConfirm:
return manageDeleteConfirmKey(m, key)
case manageRenameDrive:
return manageDrivePickerKey(m, key, manageRenameBrowse, manageActRename, false)
case manageRenameBrowse:
return manageRenameBrowseKey(m, msg)
case manageRenameInput:
return manageRenameInputKey(m, msg)
case manageRenameConfirm:
return manageRenameConfirmKey(m, key)
case manageMountPick:
return manageMountPickKey(m, key)
case manageMountConfirm:
return manageMountConfirmKey(m, key)
case manageUnmountPick:
return manageUnmountPickKey(m, key)
case manageUnmountConfirm:
return manageUnmountConfirmKey(m, key)
}
return m, nil, false
}
func manageIdleKey(m Model, key string) (Model, tea.Cmd, bool) {
if m.inventory == nil {
return m, nil, false
}
switch key {
case "c":
return startCopy(m)
case "d":
return startDelete(m)
case "n":
return startRename(m)
case "m":
return startMount(m)
case "e":
return startUnmount(m)
}
return m, nil, false
}
// --- Copy: kickoff + drive picker ---
func startCopy(m Model) (Model, tea.Cmd, bool) {
ext := m.inventory.External()
if len(ext) == 0 {
m.flash = "No external drives mounted. Plug one in and press 'r'."
return m, nil, true
}
managed := m.inventory.Managed()
if len(managed) == 0 {
m.flash = "No managed media drives. Add one from the Setup tab first."
return m, nil, true
}
m.manage.active = manageAction{kind: manageActCopy, driveChoices: ext}
if len(ext) == 1 {
m.manage.active.srcDrive = ext[0]
fb, err := newFileBrowser(ext[0].MountPoint)
if err != nil {
m.flash = "Couldn't open " + ext[0].MountPoint + ": " + err.Error()
return m, nil, true
}
m.manage.active.srcBrowse = fb
m.manage.stage = manageCopySrcBrowse
return m, nil, true
}
m.manage.stage = manageCopySrcDrive
return m, nil, true
}
// manageDrivePickerKey is shared by every "pick a drive" sub-stage. The
// `next` arg is the sub-stage to move to after the user picks. `isCopySrc`
// tells us whether to seed srcDrive (true) or dstDrive (false).
func manageDrivePickerKey(m Model, key string, next manageStage, kind manageActionKind, isCopySrc bool) (Model, tea.Cmd, bool) {
a := &m.manage.active
switch key {
case "up", "k":
if a.driveIdx > 0 {
a.driveIdx--
}
return m, nil, true
case "down", "j":
if a.driveIdx < len(a.driveChoices)-1 {
a.driveIdx++
}
return m, nil, true
case "esc":
return manageCancel(m), nil, true
case "enter":
if a.driveIdx < 0 || a.driveIdx >= len(a.driveChoices) {
return m, nil, true
}
chosen := a.driveChoices[a.driveIdx]
fb, err := newFileBrowser(chosen.MountPoint)
if err != nil {
m.flash = "Couldn't open " + chosen.MountPoint + ": " + err.Error()
return manageCancel(m), nil, true
}
if isCopySrc {
a.srcDrive = chosen
a.srcBrowse = fb
} else {
a.dstDrive = chosen
a.dstBrowse = fb
}
_ = kind // kept for future per-action divergence
m.manage.stage = next
return m, nil, true
}
return m, nil, false
}
// manageCancel returns the user to the idle Manage view and clears any
// in-flight action state.
func manageCancel(m Model) Model {
m.manage.stage = manageIdle
m.manage.active = manageAction{}
return m
}
// --- Copy: source browser ---
func manageCopySrcBrowseKey(m Model, msg tea.KeyMsg) (Model, tea.Cmd, bool) {
a := &m.manage.active
if k := msg.String(); k == "tab" || k == "shift+tab" {
return m, nil, false
}
res, cmd := a.srcBrowse.update(msg)
switch res {
case browseStillBrowsing:
return m, cmd, true
case browseCancelled:
return manageCancel(m), nil, true
case browseSelected:
// User picked something to copy. Advance to destination drive.
a.srcPath = a.srcBrowse.SelectedPath()
managed := m.inventory.Managed()
a.driveChoices = managed
a.driveIdx = 0
if len(managed) == 1 {
a.dstDrive = managed[0]
fb, err := newFileBrowser(managed[0].MountPoint)
if err != nil {
m.flash = "Couldn't open " + managed[0].MountPoint + ": " + err.Error()
return manageCancel(m), nil, true
}
a.dstBrowse = fb
m.manage.stage = manageCopyDstBrowse
return m, nil, true
}
m.manage.stage = manageCopyDstDrive
return m, nil, true
}
return m, cmd, true
}
// --- Copy: destination browser ---
func manageCopyDstBrowseKey(m Model, msg tea.KeyMsg) (Model, tea.Cmd, bool) {
a := &m.manage.active
if k := msg.String(); k == "tab" || k == "shift+tab" {
return m, nil, false
}
// In the dst browser, Enter on a folder dives in (default), Space SELECTS
// the current directory as the destination — we want "copy INTO this folder".
if msg.String() == " " || msg.String() == "space" {
a.dstPath = a.dstBrowse.cwd
m.manage.stage = manageCopyConfirm
a.confirmIdx = 0
return m, nil, true
}
res, cmd := a.dstBrowse.update(msg)
switch res {
case browseStillBrowsing:
return m, cmd, true
case browseCancelled:
return manageCancel(m), nil, true
case browseSelected:
// Selecting an item in the destination browser means "copy into this
// folder" — so if it's a file we still target its parent dir.
sel := a.dstBrowse.SelectedPath()
if isDir(sel) {
a.dstPath = sel
} else {
a.dstPath = filepath.Dir(sel)
}
m.manage.stage = manageCopyConfirm
a.confirmIdx = 0
return m, nil, true
}
return m, cmd, true
}
// --- Copy: confirm + run ---
func manageCopyConfirmKey(m Model, key string) (Model, tea.Cmd, bool) {
switch key {
case "left", "h":
m.manage.active.confirmIdx = 0
return m, nil, true
case "right", "l":
m.manage.active.confirmIdx = 1
return m, nil, true
case "y", "Y":
m.manage.active.confirmIdx = 0
return runCopy(m)
case "n", "N", "esc":
return manageCancel(m), nil, true
case "enter":
if m.manage.active.confirmIdx == 0 {
return runCopy(m)
}
return manageCancel(m), nil, true
}
return m, nil, false
}
func runCopy(m Model) (Model, tea.Cmd, bool) {
a := &m.manage.active
src := a.srcPath
dstDir := a.dstPath
// Guard against copying onto self.
if src == dstDir || strings.HasPrefix(dstDir, src+string(os.PathSeparator)) {
m.flash = "Refusing to copy a folder into itself."
return manageCancel(m), nil, true
}
plan := []step.Step{
{
Title: "Ensure destination exists: " + dstDir,
Cmd: exec.Command("mkdir", "-p", dstDir),
},
{
Title: fmt.Sprintf("rsync %s → %s/", filepath.Base(src), dstDir),
Cmd: exec.Command("rsync", "-a", "--info=progress2", src, dstDir+string(os.PathSeparator)),
},
{
Title: "Reapply ACL on destination",
Cmd: exec.Command("setfacl", "-R", "-m", "u:jellyfin:rX", dstDir),
},
}
m.run = planRun{
title: "Copying " + src,
steps: plan,
}
m.runStage = runRunning
m.runOwnerTab = tabManage
return m, runStepCmd(plan[0]), true
}
// --- Delete: kickoff + browser ---
func startDelete(m Model) (Model, tea.Cmd, bool) {
managed := m.inventory.Managed()
if len(managed) == 0 {
m.flash = "No managed media drives to delete from."
return m, nil, true
}
m.manage.active = manageAction{kind: manageActDelete, driveChoices: managed}
if len(managed) == 1 {
m.manage.active.dstDrive = managed[0]
fb, err := newFileBrowser(managed[0].MountPoint)
if err != nil {
m.flash = "Couldn't open " + managed[0].MountPoint + ": " + err.Error()
return m, nil, true
}
m.manage.active.dstBrowse = fb
m.manage.stage = manageDeleteBrowse
return m, nil, true
}
m.manage.stage = manageDeleteDrive
return m, nil, true
}
func manageDeleteBrowseKey(m Model, msg tea.KeyMsg) (Model, tea.Cmd, bool) {
a := &m.manage.active
if k := msg.String(); k == "tab" || k == "shift+tab" {
return m, nil, false
}
res, cmd := a.dstBrowse.update(msg)
switch res {
case browseStillBrowsing:
return m, cmd, true
case browseCancelled:
return manageCancel(m), nil, true
case browseSelected:
sel := a.dstBrowse.SelectedPath()
if sel == "" {
m.flash = "Pick a file or folder first."
return m, nil, true
}
if !isUnderRoot(sel, a.dstDrive.MountPoint) {
m.flash = "Refusing to delete outside the drive root."
return manageCancel(m), nil, true
}
if sel == a.dstDrive.MountPoint {
m.flash = "Refusing to delete the drive root."
return m, nil, true
}
a.dstPath = sel
m.manage.stage = manageDeleteConfirm
a.confirmIdx = 1 // default No for destructive op
return m, nil, true
}
return m, cmd, true
}
func manageDeleteConfirmKey(m Model, key string) (Model, tea.Cmd, bool) {
switch key {
case "left", "h":
m.manage.active.confirmIdx = 0
return m, nil, true
case "right", "l":
m.manage.active.confirmIdx = 1
return m, nil, true
case "y", "Y":
m.manage.active.confirmIdx = 0
return runDelete(m)
case "n", "N", "esc":
return manageCancel(m), nil, true
case "enter":
if m.manage.active.confirmIdx == 0 {
return runDelete(m)
}
return manageCancel(m), nil, true
}
return m, nil, false
}
func runDelete(m Model) (Model, tea.Cmd, bool) {
a := &m.manage.active
if a.dstPath == "" || a.dstPath == a.dstDrive.MountPoint {
m.flash = "Refusing to delete the drive root."
return manageCancel(m), nil, true
}
plan := []step.Step{
{
Title: "Delete: " + a.dstPath,
Cmd: exec.Command("rm", "-rf", "--", a.dstPath),
},
}
m.run = planRun{
title: "Deleting " + a.dstPath,
steps: plan,
}
m.runStage = runRunning
m.runOwnerTab = tabManage
return m, runStepCmd(plan[0]), true
}
// --- Rename: kickoff, browser, input, confirm ---
func startRename(m Model) (Model, tea.Cmd, bool) {
managed := m.inventory.Managed()
if len(managed) == 0 {
m.flash = "No managed media drives to rename in."
return m, nil, true
}
m.manage.active = manageAction{kind: manageActRename, driveChoices: managed}
if len(managed) == 1 {
m.manage.active.dstDrive = managed[0]
fb, err := newFileBrowser(managed[0].MountPoint)
if err != nil {
m.flash = "Couldn't open " + managed[0].MountPoint + ": " + err.Error()
return m, nil, true
}
m.manage.active.dstBrowse = fb
m.manage.stage = manageRenameBrowse
return m, nil, true
}
m.manage.stage = manageRenameDrive
return m, nil, true
}
func manageRenameBrowseKey(m Model, msg tea.KeyMsg) (Model, tea.Cmd, bool) {
a := &m.manage.active
if k := msg.String(); k == "tab" || k == "shift+tab" {
return m, nil, false
}
res, cmd := a.dstBrowse.update(msg)
switch res {
case browseStillBrowsing:
return m, cmd, true
case browseCancelled:
return manageCancel(m), nil, true
case browseSelected:
sel := a.dstBrowse.SelectedPath()
if sel == "" || sel == a.dstDrive.MountPoint {
m.flash = "Pick a file or folder (not the drive root)."
return m, nil, true
}
a.dstPath = sel
ti := textinput.New()
ti.Prompt = ""
ti.CharLimit = 128
ti.Width = 48
ti.SetValue(filepath.Base(sel))
ti.CursorEnd()
ti.Focus()
a.rename = ti
m.manage.stage = manageRenameInput
return m, nil, true
}
return m, cmd, true
}
func manageRenameInputKey(m Model, msg tea.KeyMsg) (Model, tea.Cmd, bool) {
a := &m.manage.active
switch msg.String() {
case "esc":
return manageCancel(m), nil, true
case "enter":
newName := strings.TrimSpace(a.rename.Value())
if newName == "" || strings.ContainsRune(newName, '/') {
m.flash = "Name can't be empty or contain '/'."
return m, nil, true
}
if newName == filepath.Base(a.dstPath) {
m.flash = "New name is the same — nothing to do."
return m, nil, true
}
newPath := filepath.Join(filepath.Dir(a.dstPath), newName)
if _, err := os.Stat(newPath); err == nil {
m.flash = "A file/folder named '" + newName + "' already exists here."
return m, nil, true
}
m.manage.stage = manageRenameConfirm
a.confirmIdx = 0
return m, nil, true
case "tab", "shift+tab":
return m, nil, false // let the root model handle tab-switch
}
var cmd tea.Cmd
a.rename, cmd = a.rename.Update(msg)
return m, cmd, true
}
func manageRenameConfirmKey(m Model, key string) (Model, tea.Cmd, bool) {
switch key {
case "left", "h":
m.manage.active.confirmIdx = 0
return m, nil, true
case "right", "l":
m.manage.active.confirmIdx = 1
return m, nil, true
case "y", "Y":
m.manage.active.confirmIdx = 0
return runRename(m)
case "n", "N", "esc":
return manageCancel(m), nil, true
case "enter":
if m.manage.active.confirmIdx == 0 {
return runRename(m)
}
return manageCancel(m), nil, true
}
return m, nil, false
}
func runRename(m Model) (Model, tea.Cmd, bool) {
a := &m.manage.active
newName := strings.TrimSpace(a.rename.Value())
newPath := filepath.Join(filepath.Dir(a.dstPath), newName)
plan := []step.Step{
{
Title: fmt.Sprintf("Rename %s → %s", filepath.Base(a.dstPath), newName),
Cmd: exec.Command("mv", "--", a.dstPath, newPath),
},
}
m.run = planRun{
title: "Renaming " + a.dstPath,
steps: plan,
}
m.runStage = runRunning
m.runOwnerTab = tabManage
return m, runStepCmd(plan[0]), true
}
// --- Mount: kickoff, picker, confirm, run ---
func startMount(m Model) (Model, tea.Cmd, bool) {
cands := m.inventory.Mountable()
if len(cands) == 0 {
m.flash = "No mountable drives detected. Plug one in and press 'r'."
return m, nil, true
}
m.manage.active = manageAction{
kind: manageActMount,
driveChoices: cands,
mountReadOnly: true,
}
m.manage.stage = manageMountPick
return m, nil, true
}
func manageMountPickKey(m Model, key string) (Model, tea.Cmd, bool) {
a := &m.manage.active
switch key {
case "up", "k":
if a.driveIdx > 0 {
a.driveIdx--
}
return m, nil, true
case "down", "j":
if a.driveIdx < len(a.driveChoices)-1 {
a.driveIdx++
}
return m, nil, true
case "esc":
return manageCancel(m), nil, true
case "enter":
if a.driveIdx < 0 || a.driveIdx >= len(a.driveChoices) {
return m, nil, true
}
a.srcDrive = a.driveChoices[a.driveIdx]
a.mountPoint = filepath.Join("/run/media", m.username, mountSlug(a.srcDrive))
m.manage.stage = manageMountConfirm
a.confirmIdx = 0
return m, nil, true
}
return m, nil, false
}
func manageMountConfirmKey(m Model, key string) (Model, tea.Cmd, bool) {
a := &m.manage.active
switch key {
case "left", "h":
a.confirmIdx = 0
return m, nil, true
case "right", "l":
a.confirmIdx = 1
return m, nil, true
case "w":
a.mountReadOnly = !a.mountReadOnly
return m, nil, true
case "y", "Y":
a.confirmIdx = 0
return runMount(m)
case "n", "N", "esc":
return manageCancel(m), nil, true
case "enter":
if a.confirmIdx == 0 {
return runMount(m)
}
return manageCancel(m), nil, true
}
return m, nil, false
}
func runMount(m Model) (Model, tea.Cmd, bool) {
a := &m.manage.active
mountOpts := "ro"
if !a.mountReadOnly {
mountOpts = "rw"
}
// Many removable drives (USB-attached MS-DOS-style partition tables on
// vfat / exfat) prefer the calling user owning the files. Pass uid/gid
// where the FS supports those mount options.
uid, gid := callerUIDGID(m.username)
switch a.srcDrive.FSType {
case "vfat", "exfat", "ntfs", "ntfs3":
mountOpts += fmt.Sprintf(",uid=%d,gid=%d", uid, gid)
}
plan := []step.Step{
{
Title: "Create mountpoint: " + a.mountPoint,
Cmd: exec.Command("mkdir", "-p", a.mountPoint),
},
{
Title: fmt.Sprintf("mount -o %s %s → %s", mountOpts, a.srcDrive.Path, a.mountPoint),
Cmd: exec.Command("mount", "-o", mountOpts, a.srcDrive.Path, a.mountPoint),
},
}
m.run = planRun{
title: "Mounting " + a.srcDrive.Path,
steps: plan,
}
m.runStage = runRunning
m.runOwnerTab = tabManage
return m, runStepCmd(plan[0]), true
}
// --- Unmount: kickoff, picker, confirm, run ---
func startUnmount(m Model) (Model, tea.Cmd, bool) {
ext := m.inventory.External()
if len(ext) == 0 {
m.flash = "No external drives are mounted. Nothing to eject."
return m, nil, true
}
m.manage.active = manageAction{
kind: manageActUnmount,
driveChoices: ext,
}
m.manage.stage = manageUnmountPick
return m, nil, true
}
func manageUnmountPickKey(m Model, key string) (Model, tea.Cmd, bool) {
a := &m.manage.active
switch key {
case "up", "k":
if a.driveIdx > 0 {
a.driveIdx--
}
return m, nil, true
case "down", "j":
if a.driveIdx < len(a.driveChoices)-1 {
a.driveIdx++
}
return m, nil, true
case "esc":
return manageCancel(m), nil, true
case "enter":
if a.driveIdx < 0 || a.driveIdx >= len(a.driveChoices) {
return m, nil, true
}
a.srcDrive = a.driveChoices[a.driveIdx]
a.mountPoint = a.srcDrive.MountPoint
m.manage.stage = manageUnmountConfirm
a.confirmIdx = 0
return m, nil, true
}
return m, nil, false
}
func manageUnmountConfirmKey(m Model, key string) (Model, tea.Cmd, bool) {
a := &m.manage.active
switch key {
case "left", "h":
a.confirmIdx = 0
return m, nil, true
case "right", "l":
a.confirmIdx = 1
return m, nil, true
case "y", "Y":
a.confirmIdx = 0
return runUnmount(m)
case "n", "N", "esc":
return manageCancel(m), nil, true
case "enter":
if a.confirmIdx == 0 {
return runUnmount(m)
}
return manageCancel(m), nil, true
}
return m, nil, false
}
func runUnmount(m Model) (Model, tea.Cmd, bool) {
a := &m.manage.active
plan := []step.Step{
{
Title: "sync — flush pending writes",
Cmd: exec.Command("sync"),
},
{
Title: "umount " + a.mountPoint,
Cmd: exec.Command("umount", "-v", a.mountPoint),
},
}
// If the mountpoint is one we created under /run/media/<user>/, tidy
// it up. The kernel can hold the dentry briefly after umount (rmdir
// returns EBUSY) and stale "ghost" mount entries from drives that
// were unplugged without unmounting also cause EBUSY — so this step
// is best-effort: sleeps briefly, tries rmdir, and never fails the
// plan. The eject is considered successful as soon as umount returns
// 0.
if strings.HasPrefix(a.mountPoint, "/run/media/") {
plan = append(plan, step.Step{
Title: "Tidy mountpoint (best-effort): " + a.mountPoint,
Cmd: exec.Command("sh", "-c", fmt.Sprintf(
"sleep 0.3; rmdir %q 2>/dev/null; true",
a.mountPoint,
)),
})
}
m.run = planRun{
title: "Ejecting " + a.srcDrive.Path,
steps: plan,
}
m.runStage = runRunning
m.runOwnerTab = tabManage
return m, runStepCmd(plan[0]), true
}
// mountSlug picks a stable, filesystem-safe directory name for a drive:
// the label if it's clean, else "<device>-<uuid-prefix>".
func mountSlug(d drives.Drive) string {
if d.Label != "" {
cleaned := strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
r == '-', r == '_':
return r
case r == ' ':
return '-'
}
return -1
}, d.Label)
if cleaned != "" {
return cleaned
}
}
if d.UUID != "" && len(d.UUID) >= 8 {
return filepath.Base(d.Path) + "-" + d.UUID[:8]
}
return filepath.Base(d.Path)
}
// callerUIDGID resolves a username to its numeric uid/gid. Falls back to
// 1000/1000 if the lookup fails — same default Arch's first-user setup uses.
func callerUIDGID(username string) (int, int) {
if username == "" {
return 1000, 1000
}
u, err := user.Lookup(username)
if err != nil {
return 1000, 1000
}
uid, err1 := strconv.Atoi(u.Uid)
gid, err2 := strconv.Atoi(u.Gid)
if err1 != nil || err2 != nil {
return 1000, 1000
}
return uid, gid
}
// --- small helpers ---
func isDir(path string) bool {
st, err := os.Stat(path)
if err != nil {
return false
}
return st.IsDir()
}
func isUnderRoot(path, root string) bool {
rel, err := filepath.Rel(root, path)
if err != nil {
return false
}
if rel == "." {
return true
}
return !strings.HasPrefix(rel, "..")
}