omarchy-send/internal/tui/model.go
28allday 7ab3f905ee Send and read plain-text messages
Adds LocalSend-compatible text messaging alongside file transfer.

Wire format (matches the LocalSend app): a message is a single "file" with
fileType "text/plain" whose content rides in the prepare-upload `preview`
field. The receiver returns an empty token set, so nothing is uploaded — the
text is read straight from the preview.

- client: SendMessage builds that single-file prepare-upload (no body upload).
- server: detect a message (one text file with non-empty preview), surface it
  on a new Messages() channel instead of saving a file, and respond with an
  empty file set. Messages bypass the accept prompt (auto-received); the PIN
  gate still applies.
- app: bridge the server's messages channel to the TUI as MessageMsg.
- tui: a new Messages tab lists received messages (enter to read full, d to
  delete); press `m` on a device to compose and send one. Incoming messages
  show a footer notice.

Tests: end-to-end send→receive (text intact, sender preserved, nothing written
to disk) and unit coverage of the message-detection rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:40:40 +01:00

1179 lines
30 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) and Settings screens, offers a
// file picker 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/filepicker"
"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/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 string)
SetAutoAccept(bool)
SetAlias(string)
SetReceiveDir(string)
SetPIN(string)
}
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
picker filepicker.Model
staged []string // file 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.
pinInput textinput.Model
showPin bool
sendPeer *discovery.Peer
sendPaths []string
// 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")
width, height int
quitting bool
}
// New returns the root model. ctrl may be nil (e.g. in tests).
func New(cfg config.Config, ctrl Controller) 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)
fp := filepicker.New()
if home, err := os.UserHomeDir(); err == nil {
fp.CurrentDirectory = home
}
fp.AutoHeight = false
fp.Styles.Cursor = fp.Styles.Cursor.Foreground(accent)
fp.Styles.Selected = fp.Styles.Selected.Foreground(accent).Bold(true)
fp.Styles.Directory = fp.Styles.Directory.Foreground(accent)
fp.Styles.File = fp.Styles.File.Foreground(text)
fp.Styles.FileSize = fp.Styles.FileSize.Foreground(muted)
fp.Styles.Permission = fp.Styles.Permission.Foreground(muted)
fp.Styles.Symlink = fp.Styles.Symlink.Foreground(dim)
fp.Styles.EmptyDirectory = fp.Styles.EmptyDirectory.Foreground(muted)
pin := textinput.New()
pin.Placeholder = "PIN"
pin.CharLimit = 16
compose := textinput.New()
compose.Placeholder = "type a message"
compose.CharLimit = 2000
compose.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),
}
return Model{
cfg: cfg,
ctrl: ctrl,
ips: server.LocalIPs(),
screen: screenPeers,
peerList: l,
peers: make(map[string]discovery.Peer),
fileList: fl,
marked: marked,
picker: fp,
bar: progress.New(progress.WithDefaultGradient(), progress.WithWidth(22)),
xferIndex: make(map[string]*xfer),
autoAccept: cfg.AutoAccept,
pinInput: pin,
editInputs: editInputs,
msgList: ml,
composeInput: compose,
}
}
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 ph := ih - 8; ph >= 3 {
m.picker.Height = ph
} else {
m.picker.Height = 3
}
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, 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 tea.KeyMsg:
m.notice = "" // any key clears a transient footer notice
if m.composing {
return m.updateCompose(msg)
}
if m.readingMsg != nil {
if k := msg.String(); k == "esc" || k == "q" || k == "enter" {
m.readingMsg = nil
}
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.updatePicker(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 "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 "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 "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
m.target = &peer
m.staged = nil
m.screen = screenPicker
return m, m.picker.Init()
}
}
if m.screen == screenMessages {
if it, ok := m.msgList.SelectedItem().(msgItem); ok {
msg := it.m
m.readingMsg = &msg
}
return m, nil
}
}
}
if m.pending == nil && m.screen == screenPicker {
var cmd tea.Cmd
m.picker, cmd = m.picker.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 {
m.ctrl.SendMessage(*m.composeTo, text)
m.notice = "✉ message sent to " + m.composeTo.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
}
// 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())
}
// updatePicker handles the file picker / staging screen.
func (m Model) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.screen = screenPeers
return m, nil
case "ctrl+c", "q":
m.quitting = true
return m, tea.Quit
case "a":
// Stage the folder currently being browsed; it is expanded into its
// files (structure preserved) when the transfer starts.
if dir := m.picker.CurrentDirectory; dir != "" && !contains(m.staged, dir) {
m.staged = append(m.staged, dir)
}
return m, nil
case "backspace":
if len(m.staged) > 0 {
m.staged = m.staged[:len(m.staged)-1]
}
return m, nil
case "S":
if len(m.staged) > 0 && m.target != nil && m.ctrl != nil {
m.sendPeer = m.target
m.sendPaths = m.staged
m.ctrl.Send(*m.target, m.staged, "")
m.staged = nil
m.screen = screenTransfers
}
return m, nil
}
var cmd tea.Cmd
m.picker, cmd = m.picker.Update(msg)
if ok, path := m.picker.DidSelectFile(msg); ok {
if !contains(m.staged, path) {
m.staged = append(m.staged, path)
}
}
return m, cmd
}
// 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 != "" {
m.cfg.ReceiveDir = 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 {
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
}
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.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 {
body, center = headerStyle.Render("Searching for devices on the network…"), true
} else {
body = deviceHeader() + "\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.pickerView()
}
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)
}
func (m Model) pickerView() string {
target := ""
if m.target != nil {
target = m.target.Info.Alias
}
var b strings.Builder
b.WriteString(titleStyle.Render("Send to " + target))
b.WriteString(" ")
b.WriteString(headerStyle.Render(collapseHome(m.picker.CurrentDirectory)))
b.WriteString("\n\n")
b.WriteString(m.picker.View())
b.WriteString("\n")
b.WriteString(m.stagedPanel())
return b.String()
}
// 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 adds a file, a adds the current 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) 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)},
{"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.readingMsg != nil:
return "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 "enter stage file · a add folder · backspace unstage · S send · esc back"
case m.screen == screenPeers:
return "enter send-to · m message · r refresh · / filter · 1-5 switch · 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 · d delete · 1-5 switch · q quit"
case m.screen == screenSettings:
return "e edit · a auto-accept · i icons · 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 and tolerates the ~-form a user may type into the
// receive-dir setting.
func expandHome(p string) string {
if p == "~" {
if home, err := os.UserHomeDir(); err == nil {
return home
}
return p
}
if strings.HasPrefix(p, "~/") {
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, p[2:])
}
}
return 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])
}