Multicast only finds peers on the same LAN. Reach off-LAN boxes by probing them directly over unicast (works over any routable address; Tailscale is the easy, secure choice): - discovery.Probe: unicast POST /register (https->http fallback) with a two-way handshake, so send and receive both work; offline peers age out. - internal/tailscale: Peers() shells `tailscale status --json` for online peers. - config.KnownPeers: persisted manual remotes. - main.go: watchRemotes goroutine probes knownPeers ∪ tailscale peers every 10s, in both the normal TUI path and quick-send. - TUI: `+` on Devices opens an add-remote modal (host/IP/Tailscale name). - install.sh: interactive local/remote install prompt; remote mode locks port 53317 to the Tailscale interface (ufw), with container/userspace-networking detection. README documents remote devices. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
167 lines
4.7 KiB
Go
167 lines
4.7 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/bubbles/list"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
// fileItem adapts an entry in the receive directory to bubbles/list.Item.
|
|
type fileItem struct {
|
|
path string
|
|
name string
|
|
size int64
|
|
modTime time.Time
|
|
isDir bool
|
|
}
|
|
|
|
func (i fileItem) Title() string { return i.name }
|
|
func (i fileItem) Description() string { return "" }
|
|
func (i fileItem) FilterValue() string { return i.name }
|
|
|
|
// receivedFiles lists the top-level entries in dir, newest first. In-progress
|
|
// transfers (".part" temp files) are skipped so the manage view never offers to
|
|
// delete a file that is still being written. A missing directory yields an empty
|
|
// slice and no error — nothing has been received yet.
|
|
func receivedFiles(dir string) ([]list.Item, error) {
|
|
dir = expandHome(dir)
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
files := make([]fileItem, 0, len(entries))
|
|
for _, e := range entries {
|
|
if strings.HasSuffix(e.Name(), ".part") {
|
|
continue // a transfer in flight; renamed into place on success
|
|
}
|
|
info, err := e.Info()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
files = append(files, fileItem{
|
|
path: filepath.Join(dir, e.Name()),
|
|
name: e.Name(),
|
|
size: info.Size(),
|
|
modTime: info.ModTime(),
|
|
isDir: e.IsDir(),
|
|
})
|
|
}
|
|
sort.Slice(files, func(i, j int) bool { return files[i].modTime.After(files[j].modTime) })
|
|
items := make([]list.Item, len(files))
|
|
for i, f := range files {
|
|
items[i] = f
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
// Manage list column widths.
|
|
const (
|
|
colFileName = 36
|
|
colFileSize = 12
|
|
)
|
|
|
|
// fileHeader is the dim column-header row shown above the received-files list.
|
|
// The leading pad covers the cursor bar (2) + mark column (2).
|
|
func fileHeader() string {
|
|
h := lipgloss.NewStyle().Foreground(muted)
|
|
return " " +
|
|
h.Width(colFileName).Render("Name") +
|
|
h.Width(colFileSize).Render("Size") +
|
|
h.Render("Received")
|
|
}
|
|
|
|
// fileDelegate renders each received file as one aligned table row:
|
|
//
|
|
// ▌ ✓ <name> <size> <received-at>
|
|
//
|
|
// It shares the Model's marked set by reference so toggles show immediately.
|
|
type fileDelegate struct{ marked map[string]bool }
|
|
|
|
func (fileDelegate) Height() int { return 1 }
|
|
func (fileDelegate) Spacing() int { return 0 }
|
|
func (fileDelegate) Update(tea.Msg, *list.Model) tea.Cmd { return nil }
|
|
|
|
func (d fileDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) {
|
|
it, ok := item.(fileItem)
|
|
if !ok {
|
|
return
|
|
}
|
|
name := it.name
|
|
if it.isDir {
|
|
name += "/"
|
|
}
|
|
name = truncate(name, colFileName-2)
|
|
|
|
check := " "
|
|
if d.marked[it.path] {
|
|
check = lipgloss.NewStyle().Foreground(good).Render("✓ ")
|
|
}
|
|
size := "—"
|
|
if !it.isDir {
|
|
size = humanBytes(it.size)
|
|
}
|
|
date := it.modTime.Format("2006-01-02 15:04")
|
|
|
|
nameSt := lipgloss.NewStyle().Width(colFileName)
|
|
sizeSt := lipgloss.NewStyle().Width(colFileSize)
|
|
|
|
if index == m.Index() {
|
|
fmt.Fprint(w, lipgloss.NewStyle().Foreground(accent).Render("▌ ")+check+
|
|
nameSt.Foreground(accent).Bold(true).Render(name)+
|
|
sizeSt.Foreground(accent).Render(size)+
|
|
lipgloss.NewStyle().Foreground(accent).Render(date))
|
|
return
|
|
}
|
|
fmt.Fprint(w, " "+check+
|
|
nameSt.Foreground(text).Render(name)+
|
|
sizeSt.Foreground(dim).Render(size)+
|
|
lipgloss.NewStyle().Foreground(muted).Render(date))
|
|
}
|
|
|
|
// manageView renders the received-files list plus a one-line status (delete
|
|
// error, or count marked) on the spare row beneath it.
|
|
func (m Model) manageView() string {
|
|
var b strings.Builder
|
|
b.WriteString(fileHeader())
|
|
b.WriteString("\n")
|
|
b.WriteString(m.fileList.View())
|
|
switch {
|
|
case m.manageErr != "":
|
|
b.WriteString("\n" + lipgloss.NewStyle().Foreground(bad).Render(m.manageErr))
|
|
case len(m.marked) > 0:
|
|
b.WriteString("\n" + titleStyle.Render(fmt.Sprintf("%d marked for deletion", len(m.marked))))
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// confirmDeleteView is the centered card shown before files are removed.
|
|
func (m Model) confirmDeleteView() string {
|
|
const maxShow = 8
|
|
var b strings.Builder
|
|
b.WriteString(titleStyle.Render("Delete files"))
|
|
b.WriteString("\n\n")
|
|
b.WriteString(headerStyle.Render(fmt.Sprintf("Permanently delete %d item(s) from the receive folder?", len(m.delTargets))))
|
|
b.WriteString("\n\n")
|
|
for i, p := range m.delTargets {
|
|
if i == maxShow {
|
|
b.WriteString(headerStyle.Render(fmt.Sprintf(" … and %d more", len(m.delTargets)-maxShow)))
|
|
b.WriteByte('\n')
|
|
break
|
|
}
|
|
b.WriteString(" • " + filepath.Base(p) + "\n")
|
|
}
|
|
b.WriteString("\n")
|
|
b.WriteString(footerStyle.Render("y/enter delete · n/esc cancel"))
|
|
return b.String()
|
|
}
|