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>
1386 lines
37 KiB
Go
1386 lines
37 KiB
Go
// 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])
|
||
}
|