omarchy-send/internal/tui/model.go
28allday a93cdcaa83 Expand ~ in receiveDir everywhere it becomes a filesystem path
A receiveDir stored as "~/Omarchy-Send" (hand-edited, or typed into the
Settings tab) was treated as a relative path, so the receiver silently
created a literal "~" directory under its cwd and wrote incoming files
there. The TUI expanded ~ for display only, which hid the problem.

The canonical ExpandHome now lives in config and is applied in Load
(normalised value is persisted back), in the Settings-tab save, and by
the TUI's display helper, which now delegates to it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:13:13 +01:00

1386 lines
37 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package tui implements the Bubble Tea front end. It routes between Devices,
// Transfers, Manage (received-file housekeeping), Messages and Settings screens,
// offers a recursive fuzzy file finder for sending, and raises modal prompts for
// incoming files and for confirming deletions.
package tui
import (
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"omarchy-send/internal/app"
"omarchy-send/internal/clipboard"
"omarchy-send/internal/config"
"omarchy-send/internal/discovery"
"omarchy-send/internal/server"
"omarchy-send/internal/theme"
"omarchy-send/internal/transfer"
)
// Controller lets the TUI drive the network layer.
type Controller interface {
Announce()
Send(peer discovery.Peer, paths []string, pin string)
SendMessage(peer discovery.Peer, text, pin string)
SetAutoAccept(bool)
SetAlias(string)
SetReceiveDir(string)
SetPIN(string)
SetNotify(bool)
AddKnownPeer(host string) // probe a remote host directly (off-LAN / Tailscale)
}
type screen int
const (
screenPeers screen = iota
screenTransfers
screenManage
screenMessages
screenSettings
screenPicker // entered from Peers; not part of the tab rotation
tabCount = 5 // Devices · Transfers · Manage · Messages · Settings
)
// xfer is one row in the transfers view.
type xfer struct {
key string
name string
dir transfer.Direction
received int64
total int64
state string // active | done | error | cancelled
started time.Time
}
// finished reports whether the transfer is in a terminal state.
func (x *xfer) finished() bool {
return x.state == "done" || x.state == "error" || x.state == "cancelled"
}
// Model is the root router model.
type Model struct {
cfg config.Config
ctrl Controller
ips []string
screen screen
peerList list.Model
peers map[string]discovery.Peer
// Send screen: a recursive fuzzy file finder ("fzf") rooted at fzfRoot.
fzfQuery textinput.Model // the fuzzy query input
fzfRoot string // directory currently indexed
fzfEntries []fzfEntry // files+dirs found under fzfRoot
fzfRels []string // entry paths relative to fzfRoot (match corpus)
fzfMatches []int // indexes into fzfEntries, best-ranked first
fzfCursor int // selected row within fzfMatches
fzfIndexing bool // a walk is in flight
fzfTrunc bool // the walk hit the entry cap
fzfDirsOnly bool // show only folders (pick a whole dir to send)
fzfErr string // last walk error
staged []string // file/dir paths queued to send
target *discovery.Peer // peer we're sending to
bar progress.Model
transfers []*xfer
xferIndex map[string]*xfer
pending *server.AcceptRequest // non-nil while an accept prompt is showing
// Manage (received files) tab.
fileList list.Model
marked map[string]bool // paths queued for deletion, shared with fileDelegate
manageErr string // last load/delete error, shown beneath the list
confirmDel bool // a delete-confirmation card is showing
delTargets []string // paths the pending confirmation will delete
autoAccept bool
// Settings edit form.
editing bool
editFocus int
editInputs []textinput.Model // 0=alias, 1=receive dir, 2=pin
// PIN prompt state for sending to a PIN-protected peer. pendingMsg is set
// (and sendPaths nil) when the pending send is a message rather than files,
// so the PIN retry resends the right thing.
pinInput textinput.Model
showPin bool
sendPeer *discovery.Peer
sendPaths []string
pendingMsg string
// Add-remote-peer modal (Devices tab): host/IP/Tailscale-name entry.
addingPeer bool
peerInput textinput.Model
// Messages tab + compose modal.
msgList list.Model
messages []server.ReceivedMessage
composing bool
composeInput textinput.Model
composeTo *discovery.Peer
readingMsg *server.ReceivedMessage // non-nil while reading a message full-screen
notice string // transient footer flash (e.g. "message sent")
// quickSend is set by WithStagedFiles: the TUI opens with files already
// staged and selecting a device on the Devices screen sends them straight
// away (used by the Nautilus right-click integration).
quickSend bool
// quitAfterSend (also set by WithStagedFiles) auto-closes the window once
// the outgoing transfer completes, so the right-click box doesn't linger.
// quitPending guards against scheduling more than one quit tick at a time.
quitAfterSend bool
quitPending bool
width, height int
quitting bool
}
// Option customizes a Model at construction.
type Option func(*Model)
// WithStagedFiles opens the TUI in quick-send mode: paths are pre-staged and
// picking a device on the Devices screen sends them immediately, skipping the
// file finder. Empty input is a no-op (normal startup).
func WithStagedFiles(paths []string) Option {
return func(m *Model) {
if len(paths) == 0 {
return
}
m.staged = append([]string(nil), paths...)
m.quickSend = true
m.quitAfterSend = true
}
}
// New returns the root model. ctrl may be nil (e.g. in tests).
func New(cfg config.Config, ctrl Controller, opts ...Option) Model {
applyTheme(theme.Load()) // match the active Omarchy theme
l := list.New(nil, deviceDelegate{icons: !cfg.NoIcons}, 0, 0)
l.SetShowTitle(false)
l.SetShowHelp(false)
l.SetShowStatusBar(false)
marked := make(map[string]bool)
fl := list.New(nil, fileDelegate{marked: marked}, 0, 0)
fl.SetShowTitle(false)
fl.SetShowHelp(false)
fl.SetShowStatusBar(false)
ml := list.New(nil, msgDelegate{}, 0, 0)
ml.SetShowTitle(false)
ml.SetShowHelp(false)
ml.SetShowStatusBar(false)
fzfQuery := textinput.New()
fzfQuery.Prompt = " "
fzfQuery.Placeholder = "type to fuzzy-find files…"
fzfQuery.CharLimit = 128
fzfQuery.Width = 48
fzfQuery.PromptStyle = lipgloss.NewStyle().Foreground(accent)
pin := textinput.New()
pin.Placeholder = "PIN"
pin.CharLimit = 16
compose := textinput.New()
compose.Placeholder = "type a message"
compose.CharLimit = 2000
compose.Width = 48
peerInput := textinput.New()
peerInput.Placeholder = "host, IP, or Tailscale name"
peerInput.CharLimit = 256
peerInput.Width = 48
mkInput := func(placeholder string, limit int) textinput.Model {
ti := textinput.New()
ti.Placeholder = placeholder
ti.CharLimit = limit
ti.Width = 48
return ti
}
editInputs := []textinput.Model{
mkInput("alias", 63),
mkInput("receive directory", 256),
mkInput("PIN (blank = disabled)", 16),
}
m := Model{
cfg: cfg,
ctrl: ctrl,
ips: server.LocalIPs(),
screen: screenPeers,
peerList: l,
peers: make(map[string]discovery.Peer),
fileList: fl,
marked: marked,
fzfQuery: fzfQuery,
bar: progress.New(progress.WithDefaultGradient(), progress.WithWidth(22)),
xferIndex: make(map[string]*xfer),
autoAccept: cfg.AutoAccept,
pinInput: pin,
editInputs: editInputs,
msgList: ml,
composeInput: compose,
peerInput: peerInput,
}
for _, o := range opts {
o(&m)
}
return m
}
func (m Model) Init() tea.Cmd { return nil }
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
iw, ih := innerDims(msg.Width, msg.Height)
lh := ih - 2 // leave room for the column-header row + blank
if lh < 1 {
lh = 1
}
m.peerList.SetSize(iw, lh)
m.fileList.SetSize(iw, lh)
m.msgList.SetSize(iw, lh)
if qw := iw - 4; qw > 8 {
m.fzfQuery.Width = qw
}
return m, nil
case app.PeerFoundMsg:
m.peers[msg.Peer.Info.Fingerprint] = msg.Peer
m.peerList.SetItems(m.peerItems())
return m, nil
case app.PeerLostMsg:
delete(m.peers, msg.Fingerprint)
m.peerList.SetItems(m.peerItems())
return m, nil
case app.IncomingMsg:
req := msg.Req
m.pending = &req
return m, nil
case app.TransferMsg:
// A PIN-required signal opens the PIN prompt instead of a transfer row.
if msg.Ev.Kind == transfer.Error && errors.Is(msg.Ev.Err, transfer.ErrPinRequired) {
m.showPin = true
m.pinInput.SetValue("")
m.pinInput.Focus()
return m, textinput.Blink
}
m.applyTransfer(msg.Ev)
return m, m.maybeScheduleQuit()
case autoQuitMsg:
// The debounce elapsed — quit only if the send is still complete (a new
// file may have started since, e.g. mid-folder).
m.quitPending = false
if m.allSendsDoneOK() {
m.quitting = true
return m, tea.Quit
}
return m, nil
case app.MessageMsg:
m.messages = append([]server.ReceivedMessage{msg.Msg}, m.messages...)
m.msgList.SetItems(m.msgItems())
m.notice = "✉ message from " + nonEmpty(msg.Msg.From, "a device")
return m, nil
case fzfIndexedMsg:
if msg.root != m.fzfRoot {
return m, nil // a stale walk for a root we've since left
}
m.fzfIndexing = false
m.fzfEntries = msg.entries
m.fzfTrunc = msg.trunc
if msg.err != nil {
m.fzfErr = msg.err.Error()
} else {
m.fzfErr = ""
}
m.fzfRels = make([]string, len(msg.entries))
for i, e := range msg.entries {
m.fzfRels[i] = e.rel
}
m.recomputeMatches()
return m, nil
case tea.KeyMsg:
m.notice = "" // any key clears a transient footer notice
if m.composing {
return m.updateCompose(msg)
}
if m.addingPeer {
return m.updateAddPeer(msg)
}
if m.readingMsg != nil {
switch msg.String() {
case "esc", "q", "enter":
m.readingMsg = nil
case "y":
if err := clipboard.Write(m.readingMsg.Text); err != nil {
m.notice = "clipboard unavailable"
} else {
m.notice = "copied to clipboard"
}
}
return m, nil
}
if m.showPin {
return m.updatePin(msg)
}
if m.editing {
return m.updateSettingsEdit(msg)
}
if m.confirmDel {
return m.updateConfirmDelete(msg)
}
if m.pending != nil {
return m.updateAccept(msg)
}
if m.screen == screenPicker {
return m.updateFzf(msg)
}
if m.screen == screenPeers && m.peerList.FilterState() == list.Filtering {
break
}
if m.screen == screenManage && m.fileList.FilterState() == list.Filtering {
break
}
switch msg.String() {
case "q", "ctrl+c":
m.quitting = true
return m, tea.Quit
case "1":
m.screen = screenPeers
return m, nil
case "2":
m.screen = screenTransfers
return m, nil
case "3":
m.screen = screenManage
m.refreshManage()
return m, nil
case "4":
m.screen = screenMessages
return m, nil
case "5":
m.screen = screenSettings
return m, nil
case "tab":
m.screen = (m.screen + 1) % tabCount
if m.screen == screenManage {
m.refreshManage()
}
return m, nil
case "shift+tab":
m.screen = (m.screen + tabCount - 1) % tabCount
if m.screen == screenManage {
m.refreshManage()
}
return m, nil
case "+":
// Add a remote device by host/IP/Tailscale-name (off-LAN peer).
if m.screen == screenPeers {
m.addingPeer = true
m.peerInput.SetValue("")
m.peerInput.Focus()
return m, textinput.Blink
}
return m, nil
case "r":
if m.screen == screenPeers && m.ctrl != nil {
m.ctrl.Announce()
}
if m.screen == screenManage {
m.refreshManage()
}
return m, nil
case "c":
if m.screen == screenTransfers {
m.clearFinished()
}
return m, nil
case " ":
if m.screen == screenManage {
m.toggleMark()
}
return m, nil
case "d", "x":
if m.screen == screenManage {
return m.requestDelete()
}
if m.screen == screenMessages {
m.deleteSelectedMessage()
}
return m, nil
case "m":
// Compose a message to the highlighted device.
if m.screen == screenPeers {
if it, ok := m.peerList.SelectedItem().(peerItem); ok {
peer := it.p
m.composeTo = &peer
m.composing = true
m.composeInput.SetValue("")
m.composeInput.Focus()
return m, textinput.Blink
}
}
return m, nil
case "v":
// Send the clipboard: open compose to the selected device,
// pre-filled with the clipboard text, so it can be reviewed first.
if m.screen == screenPeers {
if it, ok := m.peerList.SelectedItem().(peerItem); ok {
text, err := clipboard.Read()
if err != nil {
m.notice = "clipboard unavailable"
return m, nil
}
if strings.TrimSpace(text) == "" {
m.notice = "clipboard is empty"
return m, nil
}
peer := it.p
m.composeTo = &peer
m.composing = true
m.composeInput.SetValue(text)
m.composeInput.CursorEnd()
m.composeInput.Focus()
return m, textinput.Blink
}
}
return m, nil
case "y":
// Copy the selected/open message to the clipboard.
if m.screen == screenMessages {
var text string
if m.readingMsg != nil {
text = m.readingMsg.Text
} else if it, ok := m.msgList.SelectedItem().(msgItem); ok {
text = it.m.Text
}
if text == "" {
return m, nil
}
if err := clipboard.Write(text); err != nil {
m.notice = "clipboard unavailable"
} else {
m.notice = "copied to clipboard"
}
}
return m, nil
case "a":
if m.screen == screenManage {
m.toggleMarkAll()
return m, nil
}
if m.screen == screenSettings {
m.autoAccept = !m.autoAccept
if m.ctrl != nil {
m.ctrl.SetAutoAccept(m.autoAccept)
}
}
return m, nil
case "i":
if m.screen == screenSettings {
m.cfg.NoIcons = !m.cfg.NoIcons
m.peerList.SetDelegate(deviceDelegate{icons: !m.cfg.NoIcons})
_ = m.cfg.Save()
}
return m, nil
case "n":
if m.screen == screenSettings {
m.cfg.NoNotify = !m.cfg.NoNotify
if m.ctrl != nil {
m.ctrl.SetNotify(!m.cfg.NoNotify)
}
_ = m.cfg.Save()
}
return m, nil
case "e":
if m.screen == screenSettings {
return m.beginEdit()
}
return m, nil
case "enter":
if m.screen == screenPeers {
if it, ok := m.peerList.SelectedItem().(peerItem); ok {
peer := it.p
// Quick-send (Nautilus right-click): files are already
// staged — selecting a device sends them straight away. A
// PIN-required peer still routes through the PIN prompt via
// sendPeer/sendPaths.
if m.quickSend && len(m.staged) > 0 && m.ctrl != nil {
m.sendPeer = &peer
m.sendPaths = m.staged
m.pendingMsg = ""
m.ctrl.Send(peer, m.staged, "")
m.staged = nil
m.quickSend = false
m.screen = screenTransfers
return m, nil
}
m.target = &peer
m.staged = nil
home, err := os.UserHomeDir()
if err != nil || home == "" {
home = "."
}
m.fzfRoot = home
m.fzfQuery.SetValue("")
m.fzfQuery.Focus()
m.fzfEntries, m.fzfRels, m.fzfMatches, m.fzfCursor = nil, nil, nil, 0
m.fzfErr = ""
m.fzfDirsOnly = false
m.fzfIndexing = true
m.screen = screenPicker
return m, tea.Batch(indexFiles(home), textinput.Blink)
}
}
if m.screen == screenMessages {
if it, ok := m.msgList.SelectedItem().(msgItem); ok {
msg := it.m
m.readingMsg = &msg
}
return m, nil
}
}
}
if m.addingPeer {
var cmd tea.Cmd
m.peerInput, cmd = m.peerInput.Update(msg)
return m, cmd
}
if m.pending == nil && m.screen == screenPicker {
var cmd tea.Cmd
m.fzfQuery, cmd = m.fzfQuery.Update(msg)
return m, cmd
}
if m.pending == nil && m.screen == screenPeers {
var cmd tea.Cmd
m.peerList, cmd = m.peerList.Update(msg)
return m, cmd
}
if m.pending == nil && !m.confirmDel && m.screen == screenManage {
var cmd tea.Cmd
m.fileList, cmd = m.fileList.Update(msg)
return m, cmd
}
if m.pending == nil && m.screen == screenMessages {
var cmd tea.Cmd
m.msgList, cmd = m.msgList.Update(msg)
return m, cmd
}
return m, nil
}
// updateCompose handles the message-compose modal.
func (m Model) updateCompose(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.composing = false
m.composeTo = nil
return m, nil
case "enter":
text := strings.TrimSpace(m.composeInput.Value())
if text != "" && m.composeTo != nil && m.ctrl != nil {
peer := *m.composeTo
// Remember the pending message so a PIN prompt can resend it.
m.sendPeer = &peer
m.sendPaths = nil
m.pendingMsg = text
m.ctrl.SendMessage(peer, text, "")
m.notice = "✉ message sent to " + peer.Info.Alias
}
m.composing = false
m.composeTo = nil
return m, nil
}
var cmd tea.Cmd
m.composeInput, cmd = m.composeInput.Update(msg)
return m, cmd
}
// updateAddPeer handles the add-remote-device modal. On enter it persists the
// host to config's known-peers and asks the controller to probe it now.
func (m Model) updateAddPeer(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.addingPeer = false
return m, nil
case "enter":
host := strings.TrimSpace(m.peerInput.Value())
if host != "" {
if !contains(m.cfg.KnownPeers, host) {
m.cfg.KnownPeers = append(m.cfg.KnownPeers, host)
_ = m.cfg.Save()
}
if m.ctrl != nil {
m.ctrl.AddKnownPeer(host)
}
m.notice = "added remote " + host + " — probing…"
}
m.addingPeer = false
return m, nil
}
var cmd tea.Cmd
m.peerInput, cmd = m.peerInput.Update(msg)
return m, cmd
}
// deleteSelectedMessage drops the highlighted message from the list.
func (m *Model) deleteSelectedMessage() {
it, ok := m.msgList.SelectedItem().(msgItem)
if !ok {
return
}
for i := range m.messages {
if m.messages[i] == it.m {
m.messages = append(m.messages[:i], m.messages[i+1:]...)
break
}
}
m.msgList.SetItems(m.msgItems())
}
// beginEdit enters the settings edit form, prefilling current values.
func (m Model) beginEdit() (tea.Model, tea.Cmd) {
m.editing = true
m.editFocus = 0
m.editInputs[0].SetValue(m.cfg.Alias)
m.editInputs[1].SetValue(m.cfg.ReceiveDir)
m.editInputs[2].SetValue(m.cfg.PIN)
for i := range m.editInputs {
m.editInputs[i].Blur()
}
m.editInputs[0].Focus()
return m, textinput.Blink
}
// updateSettingsEdit drives the settings edit form.
func (m Model) updateSettingsEdit(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.editing = false
return m, nil
case "ctrl+c":
m.quitting = true
return m, tea.Quit
case "ctrl+s":
return m.saveEdit()
case "tab", "down":
m.editFocus = (m.editFocus + 1) % len(m.editInputs)
m.focusEdit()
return m, textinput.Blink
case "shift+tab", "up":
m.editFocus = (m.editFocus - 1 + len(m.editInputs)) % len(m.editInputs)
m.focusEdit()
return m, textinput.Blink
case "enter":
// Enter on the last field saves; otherwise advances.
if m.editFocus == len(m.editInputs)-1 {
return m.saveEdit()
}
m.editFocus++
m.focusEdit()
return m, textinput.Blink
}
var cmd tea.Cmd
m.editInputs[m.editFocus], cmd = m.editInputs[m.editFocus].Update(msg)
return m, cmd
}
func (m *Model) focusEdit() {
for i := range m.editInputs {
if i == m.editFocus {
m.editInputs[i].Focus()
} else {
m.editInputs[i].Blur()
}
}
}
// saveEdit persists the form to config and applies it live.
func (m Model) saveEdit() (tea.Model, tea.Cmd) {
alias := strings.TrimSpace(m.editInputs[0].Value())
dir := strings.TrimSpace(m.editInputs[1].Value())
pin := strings.TrimSpace(m.editInputs[2].Value())
if alias != "" {
m.cfg.Alias = alias
m.cfg.DeviceModel = alias
}
if dir != "" {
// Expand a typed ~-form immediately so the live server and the saved
// config both carry the absolute path.
m.cfg.ReceiveDir = config.ExpandHome(dir)
}
m.cfg.PIN = pin
_ = m.cfg.Save()
if m.ctrl != nil {
m.ctrl.SetAlias(m.cfg.Alias)
m.ctrl.SetReceiveDir(m.cfg.ReceiveDir)
m.ctrl.SetPIN(m.cfg.PIN)
}
m.editing = false
return m, nil
}
// updatePin handles the PIN entry prompt shown when a peer needs a PIN.
func (m Model) updatePin(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "enter":
pin := strings.TrimSpace(m.pinInput.Value())
m.showPin = false
m.pinInput.Blur()
if pin != "" && m.sendPeer != nil && m.ctrl != nil {
if m.pendingMsg != "" {
m.ctrl.SendMessage(*m.sendPeer, m.pendingMsg, pin)
m.notice = "✉ message sent to " + m.sendPeer.Info.Alias
m.pendingMsg = ""
} else {
m.ctrl.Send(*m.sendPeer, m.sendPaths, pin)
m.screen = screenTransfers
}
}
return m, nil
case "esc", "ctrl+c":
m.showPin = false
m.pinInput.Blur()
return m, nil
}
var cmd tea.Cmd
m.pinInput, cmd = m.pinInput.Update(msg)
return m, cmd
}
func (m Model) updateAccept(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "y", "Y", "enter":
m.pending.Reply <- server.AcceptDecision{Accept: true}
m.pending = nil
m.screen = screenTransfers
case "n", "N", "esc":
m.pending.Reply <- server.AcceptDecision{Accept: false}
m.pending = nil
}
return m, nil
}
// autoQuitMsg fires after the post-send debounce to close a quick-send window.
type autoQuitMsg struct{}
// allSendsDoneOK reports whether every transfer has completed successfully —
// the trigger for auto-closing a quick-send window. A still-active, errored, or
// cancelled transfer (or none at all) returns false, so the window stays open
// for the user to see what happened.
func (m *Model) allSendsDoneOK() bool {
if len(m.transfers) == 0 {
return false
}
for _, x := range m.transfers {
if x.state != "done" {
return false
}
}
return true
}
// maybeScheduleQuit arms a debounced auto-quit when a quick-send has fully
// completed. The delay lets the user glimpse the ✓ and lets the next file in a
// multi-file/folder send start before we re-check at fire time.
func (m *Model) maybeScheduleQuit() tea.Cmd {
if !m.quitAfterSend || m.quitPending || !m.allSendsDoneOK() {
return nil
}
m.quitPending = true
return tea.Tick(1200*time.Millisecond, func(time.Time) tea.Msg { return autoQuitMsg{} })
}
func (m *Model) applyTransfer(ev transfer.Event) {
x, ok := m.xferIndex[ev.ID]
if !ok {
x = &xfer{key: ev.ID, name: ev.FileName, dir: ev.Dir, total: ev.Total, state: "active", started: time.Now()}
m.xferIndex[ev.ID] = x
m.transfers = append(m.transfers, x)
}
if ev.FileName != "" {
x.name = ev.FileName
}
if ev.Total > 0 {
x.total = ev.Total
}
switch ev.Kind {
case transfer.Start, transfer.Progress:
x.received = ev.Received
case transfer.FileDone:
x.received = x.total
x.state = "done"
case transfer.Error:
x.state = "error"
case transfer.Cancel:
x.state = "cancelled"
}
}
func (m Model) peerItems() []list.Item {
ps := make([]discovery.Peer, 0, len(m.peers))
for _, p := range m.peers {
ps = append(ps, p)
}
sort.Slice(ps, func(i, j int) bool { return ps[i].Info.Alias < ps[j].Info.Alias })
items := make([]list.Item, len(ps))
for i, p := range ps {
items[i] = peerItem{p: p}
}
return items
}
// innerDims returns the content width/height inside the bordered frame for a
// given window size: 1 title row + 1 tab row + 1 footer row, plus a 1-cell
// border and 1-cell horizontal padding on each side.
func innerDims(w, h int) (iw, ih int) {
if w <= 0 {
w = 90
}
if h <= 0 {
h = 28
}
iw = w - 4
if iw < 20 {
iw = 20
}
ih = h - 5
if ih < 3 {
ih = 3
}
return iw, ih
}
func (m Model) View() string {
if m.quitting {
return ""
}
w, h := m.width, m.height
if w <= 0 {
w = 90
}
if h <= 0 {
h = 28
}
// Focused prompts take over the window as a centered card.
if m.showPin {
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, cardStyle.Render(m.pinView()))
}
if m.pending != nil {
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, cardStyle.Render(m.acceptView()))
}
if m.confirmDel {
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, cardStyle.Render(m.confirmDeleteView()))
}
if m.composing {
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, cardStyle.Render(m.composeView()))
}
if m.addingPeer {
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, cardStyle.Render(m.addPeerView()))
}
if m.readingMsg != nil {
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, cardStyle.Render(m.readMessageView()))
}
cw, ih := innerDims(w, h)
var body string
center := false // center short content; let populated lists fill top-down
switch m.screen {
case screenPeers:
if len(m.peers) == 0 {
msg := "Searching for devices on the network…"
if m.quickSend {
msg += fmt.Sprintf("\n\n%d item(s) ready — pick a device to send.", len(m.staged))
}
body, center = headerStyle.Render(msg), true
} else {
head := deviceHeader()
if m.quickSend {
head = titleStyle.Render(fmt.Sprintf("Send %d item(s) — select a device:", len(m.staged))) + "\n" + head
}
body = head + "\n" + m.peerList.View()
}
case screenTransfers:
if len(m.transfers) == 0 {
body, center = headerStyle.Render("No transfers yet.\nIncoming and outgoing files appear here."), true
} else {
body = transferHeader() + "\n\n" + m.transfersView()
}
case screenManage:
switch {
case m.manageErr != "" && len(m.fileList.Items()) == 0:
body, center = lipgloss.NewStyle().Foreground(bad).Render(m.manageErr), true
case len(m.fileList.Items()) == 0:
body, center = headerStyle.Render("No received files.\nFiles sent to this device appear here."), true
default:
body = m.manageView()
}
case screenMessages:
if len(m.messages) == 0 {
body, center = headerStyle.Render("No messages yet.\nSelect a device and press m to send one."), true
} else {
body = messageHeader() + "\n" + m.msgList.View()
}
case screenSettings:
if m.editing {
body, center = m.settingsEditView(), true
} else {
body, center = m.settingsView(), true
}
case screenPicker:
body = m.sendView()
}
if center {
body = centerIn(cw, ih, body)
}
// lipgloss Width includes padding but not the border, so the frame width is
// w-2 (border) while the content area inside the padding is w-4.
panel := frameStyle.Width(w - 2).Height(ih).Render(body)
return lipgloss.JoinVertical(lipgloss.Left,
m.titleBar(w),
m.tabBar(),
panel,
footerStyle.Render(m.footerText()),
)
}
// centerIn centers a multi-line block within w×h, keeping the block's lines
// left-aligned relative to each other (it pads every line to the block's width
// first, so columns stay aligned rather than each line centering on its own).
func centerIn(w, h int, s string) string {
block := lipgloss.NewStyle().Width(lipgloss.Width(s)).Render(s)
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, block)
}
// titleBar is the full-width accent bar: app name on the left, alias·IP on the right.
func (m Model) titleBar(w int) string {
left := " Omarchy-Send"
right := m.cfg.Alias
if len(m.ips) > 0 {
right += " · " + m.ips[0]
}
right += " "
gap := w - lipgloss.Width(left) - lipgloss.Width(right)
if gap < 1 {
gap = 1
}
return titleBarStyle.Width(w).Render(left + strings.Repeat(" ", gap) + right)
}
func (m Model) tabBar() string {
tab := func(label string, s screen) string {
if m.screen == s {
return tabActiveStyle.Render(label)
}
return tabInactiveStyle.Render(label)
}
return " " + tab("Devices", screenPeers) + tab("Transfers", screenTransfers) + tab("Manage", screenManage) + tab("Messages", screenMessages) + tab("Settings", screenSettings)
}
// stagedPanel renders the queued files as a bordered box (or a hint when empty).
func (m Model) stagedPanel() string {
border := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(muted).
Padding(0, 1)
if len(m.staged) == 0 {
return border.Render(headerStyle.Render("Nothing staged — enter stages the highlighted file or folder."))
}
var b strings.Builder
b.WriteString(titleStyle.Render(fmt.Sprintf("Staged · %d", len(m.staged))))
for _, p := range m.staged {
label := collapseHome(p)
if fi, err := os.Stat(p); err == nil && fi.IsDir() {
label += "/ (folder)"
}
b.WriteString("\n" + valueStyle.Render("• "+label))
}
return border.Render(b.String())
}
func (m Model) pinView() string {
target := ""
if m.sendPeer != nil {
target = m.sendPeer.Info.Alias
}
var b strings.Builder
b.WriteString(titleStyle.Render("PIN required"))
b.WriteString("\n\n")
b.WriteString(headerStyle.Render(target + " requires a PIN."))
b.WriteString("\n\n")
b.WriteString(m.pinInput.View())
b.WriteString("\n\n")
b.WriteString(footerStyle.Render("enter send · esc cancel"))
return b.String()
}
func (m Model) addPeerView() string {
var b strings.Builder
b.WriteString(titleStyle.Render("Add remote device"))
b.WriteString("\n\n")
b.WriteString(headerStyle.Render("A device off your LAN — e.g. a Tailscale name or IP.\nIt's probed directly (no multicast) and saved."))
b.WriteString("\n\n")
b.WriteString(m.peerInput.View())
b.WriteString("\n\n")
b.WriteString(footerStyle.Render("enter add · esc cancel"))
return b.String()
}
func (m Model) acceptView() string {
var b strings.Builder
b.WriteString(titleStyle.Render("Incoming files"))
b.WriteString("\n\n")
b.WriteString(valueStyle.Render(m.pending.From.Alias))
b.WriteString(headerStyle.Render(fmt.Sprintf(" · %d file(s) · %s", len(m.pending.Files), humanBytes(m.pending.TotalSize))))
b.WriteString("\n\n")
names := make([]string, 0, len(m.pending.Files))
for _, f := range m.pending.Files {
names = append(names, f.FileName)
}
sort.Strings(names)
for _, n := range names {
b.WriteString(" • " + n + "\n")
}
b.WriteString("\n")
b.WriteString(footerStyle.Render("y/enter accept · n/esc reject"))
return b.String()
}
// transferHeader is the dim column-header row shown above the transfers list.
func transferHeader() string {
h := lipgloss.NewStyle().Foreground(muted)
return " " + h.Width(24).Render("Name") + h.Width(25).Render("Progress") + h.Render("Size")
}
func (m Model) transfersView() string {
var b strings.Builder
for _, x := range m.transfers {
ratio := 0.0
if x.total > 0 {
ratio = float64(x.received) / float64(x.total)
}
if ratio > 1 {
ratio = 1
}
arrow := "↓"
if x.dir == transfer.Outgoing {
arrow = "↑"
}
var status string
switch x.state {
case "active":
status = fmt.Sprintf("%s/%s", humanBytes(x.received), humanBytes(x.total))
case "done":
status = lipgloss.NewStyle().Foreground(good).Render("✓ done")
case "error":
status = lipgloss.NewStyle().Foreground(bad).Render("✗ error")
case "cancelled":
status = lipgloss.NewStyle().Foreground(muted).Render("cancelled")
}
name := lipgloss.NewStyle().Foreground(text).Width(22).Render(truncate(x.name, 22))
b.WriteString(fmt.Sprintf("%s %s %s %s\n", arrow, name, m.bar.ViewAs(ratio), status))
}
return b.String()
}
// rateETA returns a "1.2 MiB/s · 3s left" string for an active transfer.
func (m Model) rateETA(x *xfer) string {
elapsed := time.Since(x.started).Seconds()
if elapsed < 0.3 || x.received == 0 {
return ""
}
rate := float64(x.received) / elapsed // bytes/sec
out := fmt.Sprintf("%s/s", humanBytes(int64(rate)))
if x.total > x.received && rate > 0 {
eta := time.Duration(float64(x.total-x.received)/rate) * time.Second
out += fmt.Sprintf(" · %s left", eta.Round(time.Second))
}
return out
}
// refreshManage reloads the received-files list and prunes marks for files that
// no longer exist on disk.
func (m *Model) refreshManage() {
items, err := receivedFiles(m.cfg.ReceiveDir)
if err != nil {
m.manageErr = err.Error()
} else {
m.manageErr = ""
}
m.fileList.SetItems(items)
live := make(map[string]bool, len(items))
for _, raw := range items {
live[raw.(fileItem).path] = true
}
for p := range m.marked {
if !live[p] {
delete(m.marked, p)
}
}
}
// toggleMark flips the deletion mark on the file under the cursor.
func (m *Model) toggleMark() {
it, ok := m.fileList.SelectedItem().(fileItem)
if !ok {
return
}
if m.marked[it.path] {
delete(m.marked, it.path)
} else {
m.marked[it.path] = true
}
}
// toggleMarkAll marks every listed file, or clears all marks if they are
// already fully marked.
func (m *Model) toggleMarkAll() {
items := m.fileList.Items()
allMarked := len(items) > 0
for _, raw := range items {
if !m.marked[raw.(fileItem).path] {
allMarked = false
break
}
}
for _, raw := range items {
p := raw.(fileItem).path
if allMarked {
delete(m.marked, p)
} else {
m.marked[p] = true
}
}
}
// requestDelete gathers the deletion targets — the marked files, or the file
// under the cursor when nothing is marked — and raises the confirm card.
func (m Model) requestDelete() (tea.Model, tea.Cmd) {
var targets []string
for _, raw := range m.fileList.Items() {
it := raw.(fileItem)
if m.marked[it.path] {
targets = append(targets, it.path)
}
}
if len(targets) == 0 {
if it, ok := m.fileList.SelectedItem().(fileItem); ok {
targets = append(targets, it.path)
}
}
if len(targets) == 0 {
return m, nil
}
m.delTargets = targets
m.confirmDel = true
return m, nil
}
// updateConfirmDelete handles the delete-confirmation card.
func (m Model) updateConfirmDelete(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "y", "Y", "enter":
m.doDelete()
m.confirmDel = false
m.delTargets = nil
case "n", "N", "esc", "ctrl+c":
m.confirmDel = false
m.delTargets = nil
}
return m, nil
}
// doDelete removes the queued targets from disk, then reloads the list. As a
// guard against deleting anything outside the receive folder, only direct
// children of it are removed.
func (m *Model) doDelete() {
recv := filepath.Clean(expandHome(m.cfg.ReceiveDir))
var failed []string
for _, p := range m.delTargets {
if filepath.Dir(p) != recv {
failed = append(failed, filepath.Base(p))
continue
}
if err := os.RemoveAll(p); err != nil {
failed = append(failed, filepath.Base(p))
continue
}
delete(m.marked, p)
}
m.refreshManage()
if len(failed) > 0 {
m.manageErr = "failed to delete: " + strings.Join(failed, ", ")
}
}
// clearFinished drops terminal transfers from the list.
func (m *Model) clearFinished() {
kept := m.transfers[:0]
for _, x := range m.transfers {
if x.finished() {
delete(m.xferIndex, x.key)
} else {
kept = append(kept, x)
}
}
m.transfers = kept
}
func (m Model) settingsView() string {
if m.editing {
return m.settingsEditView()
}
var b strings.Builder
rows := [][2]string{
{"Alias", m.cfg.Alias},
{"Device type", m.cfg.DeviceType},
{"Protocol", m.cfg.Protocol},
{"Fingerprint", m.cfg.Fingerprint},
{"Port", fmt.Sprintf("%d", m.cfg.Port)},
{"Receive dir", m.cfg.ReceiveDir},
{"Auto-accept", boolStr(m.autoAccept)},
{"PIN", boolStr(m.cfg.PIN != "")},
{"Icons", boolStr(!m.cfg.NoIcons)},
{"Notifications", boolStr(!m.cfg.NoNotify)},
{"Local IPs", strings.Join(m.ips, ", ")},
}
for _, r := range rows {
b.WriteString(labelStyle.Render(r[0]))
b.WriteString(valueStyle.Render(r[1]))
b.WriteByte('\n')
}
return b.String()
}
// settingsEditView renders the editable settings form.
func (m Model) settingsEditView() string {
labels := []string{"Alias", "Receive dir", "PIN"}
var b strings.Builder
b.WriteString(titleStyle.Render("Edit settings"))
b.WriteString("\n\n")
for i, ti := range m.editInputs {
marker := " "
if i == m.editFocus {
marker = "> "
}
b.WriteString(marker + labelStyle.Render(labels[i]) + ti.View() + "\n")
}
return b.String()
}
// footerText is the contextual help line shown at the bottom of the window.
func (m Model) footerText() string {
switch {
case m.notice != "":
return m.notice
case m.composing:
return "enter send · esc cancel"
case m.addingPeer:
return "enter add remote · esc cancel"
case m.readingMsg != nil:
return "y copy · esc/enter close"
case m.confirmDel:
return "y/enter delete · n/esc cancel"
case m.editing:
return "tab/↑↓ move · enter next · ctrl+s save · esc cancel"
case m.screen == screenPicker:
return "type filter · ↑↓ move · enter stage · ctrl+d folders-only · ctrl+s send · ctrl+u up-dir · esc back"
case m.screen == screenPeers && m.quickSend:
return fmt.Sprintf("enter send %d item(s) to selected device · r refresh · q cancel", len(m.staged))
case m.screen == screenPeers:
return "enter send-to · m message · v clipboard · + add remote · r refresh · / filter · q quit"
case m.screen == screenTransfers:
return "c clear finished · 1-5 switch · q quit"
case m.screen == screenManage:
return "space mark · a all · d delete · r refresh · / filter · 1-5 switch · q quit"
case m.screen == screenMessages:
return "enter read · y copy · d delete · 1-5 switch · q quit"
case m.screen == screenSettings:
return "e edit · a auto-accept · i icons · n notify · 1-5 switch · q quit"
}
return "q quit"
}
func boolStr(v bool) string {
if v {
return "on"
}
return "off"
}
func contains(ss []string, s string) bool {
for _, x := range ss {
if x == s {
return true
}
}
return false
}
// collapseHome shortens an absolute path under $HOME to a ~-prefixed form.
func collapseHome(p string) string {
if home, err := os.UserHomeDir(); err == nil && home != "" && strings.HasPrefix(p, home) {
return "~" + p[len(home):]
}
return p
}
// expandHome resolves a leading ~ (or ~/) to the user's home directory. It is
// the inverse of collapseHome; the canonical implementation lives in config so
// every consumer of ReceiveDir expands the same way.
func expandHome(p string) string {
return config.ExpandHome(p)
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
if n <= 1 {
return s[:n]
}
return s[:n-1] + "…"
}
func humanBytes(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := int64(unit), 0
for x := n / unit; x >= unit; x /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}