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>
1179 lines
30 KiB
Go
1179 lines
30 KiB
Go
// 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])
|
||
}
|