Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions cmd/setup/commands.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package setup

import (
"fmt"
"os"
"strings"

tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/x/ansi"

"github.com/launchdarkly/ldcli/internal/setup"
)
Expand Down Expand Up @@ -122,3 +124,32 @@ func (m wizardModel) runVerify() tea.Cmd {
return verifyDoneMsg{result: result}
}
}

// copyableContent returns the code the current screen is asking the user to copy,
// along with the word the hint uses for it. A screen can show both an install command
// and a snippet; the snippet is the one that has to be pasted verbatim, so it wins.
// Returns false when the screen has nothing to copy.
func (m wizardModel) copyableContent() (content, label string, ok bool) {
if m.step != stepDone {
return "", "", false
}
if m.initResult != nil && !m.initResult.Success && m.initResult.Snippet != "" {
return m.initResult.Snippet, "snippet", true
}
if m.installResult != nil && m.installResult.Failed && m.installResult.Command != "" {
return m.installResult.Command, "command", true
}
return "", "", false
}

// copyToClipboard writes an OSC 52 sequence, which asks the terminal to put the
// content on the system clipboard. The wizard renders code inside a bordered block
// and runs in the alternate screen, so selecting it with the mouse picks up the
// gutter characters and the snippet is gone from scrollback once the wizard exits.
// Terminals that do not implement OSC 52 ignore the sequence.
func (m wizardModel) copyToClipboard(content string) tea.Cmd {
return func() tea.Msg {
fmt.Fprint(m.clipboard, ansi.SetSystemClipboard(content))
return nil
}
}
185 changes: 185 additions & 0 deletions cmd/setup/copy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
package setup

import (
"bytes"
"encoding/base64"
"testing"

tea "github.com/charmbracelet/bubbletea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/launchdarkly/ldcli/internal/setup"
)

const snippet = "const LaunchDarkly = require('@launchdarkly/node-server-sdk');\nconst ldClient = LaunchDarkly.init('sdk-key');"

// copyKey sends "c" and runs whatever command Update returns, returning the updated
// model and everything written to the clipboard writer.
func copyKey(t *testing.T, m wizardModel) (wizardModel, string) {
t.Helper()
var out bytes.Buffer
m.clipboard = &out

next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}})
if cmd != nil {
cmd()
}
return next.(wizardModel), out.String()
}

// The snippet has to arrive on the clipboard exactly as the user needs to paste it:
// the gutter bar the code block is drawn with, and the padding lipgloss adds to square
// it off, are display only and must not be copied.
func TestWizard_CopySnippet_CopiesRawContent(t *testing.T) {
m := wizardModel{
step: stepDone,
width: 80,
initResult: &setup.InitResult{
SDKID: "go-server-sdk",
FilePath: "/proj/main.go",
Snippet: snippet,
Success: false,
},
}

// The rendered block carries the decoration the raw copy must not.
require.Contains(t, m.View(), "│", "the code block is drawn with a gutter bar")

updated, written := copyKey(t, m)

require.NotEmpty(t, written, "pressing c must write an OSC 52 sequence")
assert.Equal(t, "\x1b]52;c;"+base64.StdEncoding.EncodeToString([]byte(snippet))+"\x07", written)
assert.True(t, updated.copied)

decoded := decodeOSC52(t, written)
assert.Equal(t, snippet, decoded)
assert.NotContains(t, decoded, "│", "the gutter bar must not be copied")
assert.NotContains(t, decoded, " \n", "trailing padding must not be copied")
}

// A screen can show both an install command and a snippet. The snippet is the one
// that has to be pasted verbatim, so that is what c copies.
func TestWizard_CopySnippet_PrefersSnippetOverInstallCommand(t *testing.T) {
m := wizardModel{
step: stepDone,
width: 80,
installResult: &setup.InstallResult{
Command: "npm install @launchdarkly/node-server-sdk",
Failed: true,
},
initResult: &setup.InitResult{
SDKID: "node-server",
FilePath: "/proj/index.js",
Snippet: snippet,
Success: false,
},
}

_, written := copyKey(t, m)
assert.Equal(t, snippet, decodeOSC52(t, written))
assert.Contains(t, m.View(), "Press c to copy the snippet.")
}

// With no snippet to paste, the thing the user still has to carry out of the wizard
// is the install command.
func TestWizard_CopySnippet_FallsBackToInstallCommand(t *testing.T) {
m := wizardModel{
step: stepDone,
width: 80,
installResult: &setup.InstallResult{
Command: "npm install @launchdarkly/node-server-sdk",
Failed: true,
},
initResult: &setup.InitResult{SDKID: "node-server", FilePath: "/proj/index.js", Success: true},
}

_, written := copyKey(t, m)
assert.Equal(t, "npm install @launchdarkly/node-server-sdk", decodeOSC52(t, written))
assert.Contains(t, m.View(), "Press c to copy the command.")
}

// Offering a copy on a screen with nothing to copy, or writing to the terminal on a
// key the screen does not handle, would both be wrong.
func TestWizard_CopySnippet_NothingToCopy(t *testing.T) {
tests := []struct {
name string
m wizardModel
}{
{
name: "verification succeeded, no manual step left",
m: wizardModel{
step: stepDone,
width: 80,
initResult: &setup.InitResult{SDKID: "node-server", Success: true},
verifyResult: &setup.VerifyResult{Active: true},
detectResult: &setup.DetectResult{SDKID: "node-server"},
},
},
{
name: "mid-flow screen shows no code",
m: wizardModel{step: stepSelectSDK, width: 80},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
updated, written := copyKey(t, tt.m)

assert.Empty(t, written, "must not write to the terminal with nothing to copy")
assert.False(t, updated.copied)
assert.NotContains(t, tt.m.View(), "Press c to copy")
})
}
}

// The hint has to confirm the copy, otherwise the user has no way to tell whether the
// key did anything.
func TestWizard_CopySnippet_HintConfirmsAfterCopying(t *testing.T) {
m := wizardModel{
step: stepDone,
width: 80,
initResult: &setup.InitResult{
SDKID: "go-server-sdk",
FilePath: "/proj/main.go",
Snippet: snippet,
Success: false,
},
}

assert.Contains(t, m.View(), "Press c to copy the snippet.")

updated, _ := copyKey(t, m)
view := updated.View()
assert.Contains(t, view, "Copied the snippet to your clipboard.")
assert.NotContains(t, view, "Press c to copy")
}

// 'c' is a legal character in a filter query, so the list has to keep receiving it.
func TestWizard_CopySnippet_DoesNotStealCFromFiltering(t *testing.T) {
m := wizardModel{step: stepDetect, width: 80, height: 30}
next, _ := m.Update(detectDoneMsg{result: &setup.DetectResult{
SDKID: "node-server",
Language: "JavaScript",
}})
m2 := next.(wizardModel)
m2.sdkFocus = 1

// Open the list filter, then type "c".
filtering, _ := m2.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}})
m3 := filtering.(wizardModel)
require.True(t, m3.isFiltering(), "expected the SDK list to be filtering")

typed, written := copyKey(t, m3)
assert.Empty(t, written, "c must reach the filter, not the clipboard")
assert.False(t, typed.copied)
}

func decodeOSC52(t *testing.T, seq string) string {
t.Helper()
require.True(t, len(seq) > len("\x1b]52;c;")+1, "not an OSC 52 sequence: %q", seq)
payload := seq[len("\x1b]52;c;") : len(seq)-1]
decoded, err := base64.StdEncoding.DecodeString(payload)
require.NoError(t, err)
return string(decoded)
}
14 changes: 12 additions & 2 deletions cmd/setup/model.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package setup

import (
"io"
"os"

"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
Expand Down Expand Up @@ -70,6 +73,12 @@ type wizardModel struct {
initResult *setup.InitResult
verifyResult *setup.VerifyResult

// clipboard receives the OSC 52 sequence that copies a snippet. It is the
// terminal the TUI is drawing to, kept as a field so tests can read back the
// sequence instead of writing to the real terminal.
clipboard io.Writer
copied bool // whether the visible snippet has been copied, to confirm in the view

quitting bool
}

Expand Down Expand Up @@ -143,8 +152,9 @@ func runSetupWizard(
AccessToken: viper.GetString(cliflags.AccessTokenFlag),
BaseURI: viper.GetString(cliflags.BaseURIFlag),
},
step: stepSelectProject,
spinner: s,
step: stepSelectProject,
spinner: s,
clipboard: os.Stdout,
}

p := tea.NewProgram(m, tea.WithAltScreen())
Expand Down
10 changes: 10 additions & 0 deletions cmd/setup/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
break // let the list receive the key as filter input
}
return m.handleBack()
case "c":
if m.isFiltering() {
break // let the list receive the key as filter input
}
content, _, ok := m.copyableContent()
if !ok {
break
}
m.copied = true
return m, m.copyToClipboard(content)
case "enter":
return m.handleEnter()
}
Expand Down
19 changes: 17 additions & 2 deletions cmd/setup/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,20 @@ import (

var quitHint = "\n" + mutedStyle.Render("Press q to quit.") + "\n"

// copyHint labels the copy action next to a code block, or confirms the copy once
// it has happened. The block is drawn with a left gutter bar and the wizard owns the
// alternate screen, so selecting the code by hand picks up the gutter characters.
func (m wizardModel) copyHint() string {
_, label, ok := m.copyableContent()
if !ok {
return ""
}
if m.copied {
return mutedStyle.Render(fmt.Sprintf("Copied the %s to your clipboard.", label)) + "\n"
}
return mutedStyle.Render(fmt.Sprintf("Press c to copy the %s.", label)) + "\n"
}

func (m wizardModel) View() string {
if m.quitting {
return ""
Expand Down Expand Up @@ -75,7 +89,7 @@ func (m wizardModel) View() string {
"\n\n" + code(m.initResult.Snippet) + "\n"
}
body += "\n" + m.wrap(fmt.Sprintf("Flag %q was created in project %q.", m.flagKey, m.selectedProject)) + "\n"
return body + quitHint
return body + "\n" + m.copyHint() + quitHint
}
if m.initResult != nil && !m.initResult.Success {
body := titleStyle.Render("Manual SDK setup required") + "\n\n"
Expand All @@ -88,7 +102,8 @@ func (m wizardModel) View() string {
return body +
fmt.Sprintf("Follow the setup guide at: %s\n\n", m.initResult.DocsURL) +
fmt.Sprintf("Flag %q has been created in project %q.\n", m.flagKey, m.selectedProject) +
"Once you've initialized the SDK manually, your flag will be ready to use.\n" +
"Once you've initialized the SDK manually, your flag will be ready to use.\n\n" +
m.copyHint() +
quitHint
}
if m.verifyResult != nil && m.verifyResult.Active && m.detectResult != nil {
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ require (
github.com/charmbracelet/bubbletea v1.3.6
github.com/charmbracelet/glamour v0.10.0
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/charmbracelet/x/ansi v0.9.3
github.com/getkin/kin-openapi v0.135.0
github.com/google/uuid v1.6.0
github.com/gorilla/handlers v1.5.2
Expand Down Expand Up @@ -42,7 +43,6 @@ require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.9.3 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
Expand Down
Loading