feat(cli): show download progress and copy setup links
This commit is contained in:
12 files changed
+230
-17
No files matched your search
@@ -2,10 +2,14 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/atotto/clipboard"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
// summaryModel is the terminal screen of the setup wizard. On a first run it
|
||||
@@ -24,9 +28,14 @@ type summaryModel struct {
|
||||
alreadySetUp bool // re-run: Owner pre-existed
|
||||
localHint bool // show the self-signed-cert note
|
||||
// alerts is where the watchdog's alerts go; nil leaves the rows out.
|
||||
alerts *alertRoute
|
||||
alerts *alertRoute
|
||||
copyText func(string) error
|
||||
copyNotice string
|
||||
copyFailed bool
|
||||
}
|
||||
|
||||
type summaryCopiedMsg struct{ err error }
|
||||
|
||||
func (m *summaryModel) Init() tea.Cmd { return nil }
|
||||
|
||||
// arrowNavOK lets the root repurpose ←/→ to walk back through completed steps;
|
||||
@@ -34,9 +43,35 @@ func (m *summaryModel) Init() tea.Cmd { return nil }
|
||||
func (m *summaryModel) arrowNavOK() bool { return true }
|
||||
|
||||
func (m *summaryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if copied, ok := msg.(summaryCopiedMsg); ok {
|
||||
m.copyFailed = copied.err != nil
|
||||
switch {
|
||||
case copied.err != nil:
|
||||
m.copyNotice = "Could not copy: " + copied.err.Error()
|
||||
case os.Getenv("SSH_TTY") != "" || os.Getenv("SSH_CONNECTION") != "":
|
||||
m.copyNotice = "Copy sent to terminal. If it does not paste, your terminal needs OSC 52 support."
|
||||
default:
|
||||
m.copyNotice = "Link copied to clipboard."
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "c", "C":
|
||||
link := m.panelURL
|
||||
if m.setupTokenURL != "" {
|
||||
link = m.setupTokenURL
|
||||
}
|
||||
if link == "" {
|
||||
m.copyNotice, m.copyFailed = "No link to copy.", true
|
||||
return m, nil
|
||||
}
|
||||
copyText := m.copyText
|
||||
if copyText == nil {
|
||||
copyText = copyTerminalText
|
||||
}
|
||||
return m, func() tea.Msg { return summaryCopiedMsg{err: copyText(link)} }
|
||||
case "n", "N":
|
||||
return m, func() tea.Msg { return reconfigureConnectMsg{} }
|
||||
case "s", "S":
|
||||
return m, func() tea.Msg { return reconfigureStorageMsg{} }
|
||||
@@ -101,10 +136,40 @@ func (m *summaryModel) View() string {
|
||||
b.WriteString(tuiHint.Render(" The local certificate is self-signed; your browser may warn on first visit.") + "\n")
|
||||
}
|
||||
|
||||
b.WriteString("\n" + tuiAction("c", "change connection", "s", "change storage", "e", "configure email", "enter/esc", "exit"))
|
||||
if m.copyNotice != "" {
|
||||
style := tuiOK
|
||||
if m.copyFailed {
|
||||
style = tuiWarn
|
||||
}
|
||||
b.WriteString("\n" + style.Render(m.copyNotice) + "\n")
|
||||
}
|
||||
copyLabel := "copy panel link"
|
||||
if m.setupTokenURL != "" {
|
||||
copyLabel = "copy setup link"
|
||||
}
|
||||
b.WriteString("\n" + tuiAction("c", copyLabel, "n", "change connection", "s", "change storage", "e", "configure email", "enter/esc", "exit"))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// SSH copies through the terminal; the remote host's desktop clipboard is unrelated.
|
||||
// /dev/tty keeps the one-time link out of redirected stdout and install logs.
|
||||
func copyTerminalText(text string) error {
|
||||
if os.Getenv("SSH_TTY") == "" && os.Getenv("SSH_CONNECTION") == "" {
|
||||
return clipboard.WriteAll(text)
|
||||
}
|
||||
tty, err := os.OpenFile("/dev/tty", os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tty.Close()
|
||||
sequence := ansi.SetSystemClipboard(text)
|
||||
if os.Getenv("TMUX") != "" {
|
||||
sequence = ansi.TmuxPassthrough(sequence)
|
||||
}
|
||||
_, err = io.WriteString(tty, sequence)
|
||||
return err
|
||||
}
|
||||
|
||||
// alertRoute is where this host's watchdog alerts go, as the summary shows it:
|
||||
// by mail through the [smtp] relay to the Owners' verified addresses, and the
|
||||
// heartbeat that notices the host itself going down (docs/troubleshooting.md
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
func TestSummaryCopyLink(t *testing.T) {
|
||||
t.Setenv("SSH_TTY", "")
|
||||
t.Setenv("SSH_CONNECTION", "")
|
||||
for _, tc := range []struct {
|
||||
name, setup, want string
|
||||
}{
|
||||
{"first login", "https://panel.example/setup?token=test-token", "https://panel.example/setup?token=test-token"},
|
||||
{"already set up", "", "https://panel.example"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var copied string
|
||||
m := &summaryModel{
|
||||
panelURL: "https://panel.example", setupTokenURL: tc.setup,
|
||||
copyText: func(text string) error { copied = text; return nil },
|
||||
}
|
||||
_, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("c")})
|
||||
if cmd == nil {
|
||||
t.Fatal("copy did not return a command")
|
||||
}
|
||||
msg := cmd()
|
||||
if _, ok := msg.(summaryCopiedMsg); !ok {
|
||||
t.Fatalf("c returned %T; must copy, not reconfigure", msg)
|
||||
}
|
||||
m.Update(msg)
|
||||
if copied != tc.want || m.copyFailed || !strings.Contains(m.View(), "Link copied to clipboard.") {
|
||||
t.Fatalf("copied %q, notice %q", copied, m.copyNotice)
|
||||
}
|
||||
_, cmd = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")})
|
||||
if _, ok := cmd().(reconfigureConnectMsg); !ok {
|
||||
t.Fatal("n must still allow changing the connection")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummaryCopyFailureAndSSHFeedback(t *testing.T) {
|
||||
t.Setenv("SSH_TTY", "/dev/pts/1")
|
||||
m := &summaryModel{
|
||||
panelURL: "https://panel.example",
|
||||
copyText: func(string) error { return errors.New("clipboard unavailable") },
|
||||
}
|
||||
_, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("C")})
|
||||
m.Update(cmd())
|
||||
if !m.copyFailed || !strings.Contains(m.View(), "clipboard unavailable") {
|
||||
t.Fatal("copy failure must be visible")
|
||||
}
|
||||
m.Update(summaryCopiedMsg{})
|
||||
if m.copyFailed || !strings.Contains(m.View(), "Copy sent to terminal") || strings.Contains(m.View(), "Link copied to clipboard") {
|
||||
t.Fatal("OSC 52 has no confirmation; do not claim the local clipboard was updated")
|
||||
}
|
||||
if _, cmd := (&summaryModel{}).Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("c")}); cmd != nil {
|
||||
t.Fatal("a missing link must not trigger a clipboard write")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user