diff --git a/cmd/setup/commands.go b/cmd/setup/commands.go index 470600cb..1da5556a 100644 --- a/cmd/setup/commands.go +++ b/cmd/setup/commands.go @@ -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" ) @@ -122,3 +124,36 @@ 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 puts the content on the clipboard, preferring the operating +// system's own clipboard because it works in every terminal and reports whether it +// succeeded. OSC 52 is the fallback: it asks the terminal to do the copying, which is +// what works over SSH, where the OS clipboard belongs to the wrong machine. Not every +// terminal implements OSC 52 and support cannot be queried, so a copy that goes that +// route is reported as a request rather than a result. +func (m wizardModel) copyToClipboard(content string) tea.Cmd { + return func() tea.Msg { + if err := m.nativeCopy(content); err == nil { + return copiedMsg{viaTerminal: false} + } + fmt.Fprint(m.clipboard, ansi.SetSystemClipboard(content)) + return copiedMsg{viaTerminal: true} + } +} diff --git a/cmd/setup/copy_test.go b/cmd/setup/copy_test.go new file mode 100644 index 00000000..641ccc32 --- /dev/null +++ b/cmd/setup/copy_test.go @@ -0,0 +1,233 @@ +package setup + +import ( + "bytes" + "encoding/base64" + "errors" + "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" with a working OS clipboard, and returns the updated model +// alongside what each path received. +func copyKey(t *testing.T, m wizardModel) (wizardModel, string) { + t.Helper() + var native string + updated, terminal := copyKeyWith(t, m, func(s string) error { + native = s + return nil + }) + return updated, native + terminal +} + +// copyKeyWith sends "c" with the given OS clipboard behaviour, and returns the +// updated model and whatever was written to the terminal as an OSC 52 sequence. +func copyKeyWith(t *testing.T, m wizardModel, native func(string) error) (wizardModel, string) { + t.Helper() + var out bytes.Buffer + m.clipboard = &out + m.nativeCopy = native + + next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}}) + m = next.(wizardModel) + if cmd != nil { + if msg := cmd(); msg != nil { + next, _ = m.Update(msg) + m = next.(wizardModel) + } + } + return m, 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") + + var native string + updated, _ := copyKeyWith(t, m, func(s string) error { + native = s + return nil + }) + + assert.Equal(t, snippet, native) + assert.Equal(t, copyDone, updated.copyState) + assert.NotContains(t, native, "│", "the gutter bar must not be copied") + assert.NotContains(t, native, " \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, + }, + } + + _, copied := copyKey(t, m) + assert.Equal(t, snippet, copied) + 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}, + } + + _, copied := copyKey(t, m) + assert.Equal(t, "npm install @launchdarkly/node-server-sdk", copied) + 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 copy anything with nothing to copy") + assert.Equal(t, copyNone, updated.copyState) + 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.Equal(t, copyNone, typed.copyState) +} + +// Over SSH the OS clipboard belongs to the wrong machine, so a failure there falls +// back to asking the terminal. That path cannot be confirmed, so the hint must not +// claim the content is on the clipboard. +func TestWizard_CopySnippet_FallsBackToTerminalWhenOSClipboardFails(t *testing.T) { + m := wizardModel{ + step: stepDone, + width: 80, + initResult: &setup.InitResult{ + SDKID: "go-server-sdk", + FilePath: "/proj/main.go", + Snippet: snippet, + Success: false, + }, + } + + updated, written := copyKeyWith(t, m, func(string) error { + return errors.New("no clipboard on this machine") + }) + + require.NotEmpty(t, written, "a failed OS copy must fall back to OSC 52") + assert.Equal(t, "\x1b]52;c;"+base64.StdEncoding.EncodeToString([]byte(snippet))+"\x07", written) + assert.Equal(t, snippet, decodeOSC52(t, written)) + assert.Equal(t, copyRequested, updated.copyState) + + view := updated.View() + assert.Contains(t, view, "Asked your terminal to copy the snippet.") + assert.NotContains(t, view, "Copied the snippet to your clipboard.", + "OSC 52 support cannot be detected, so the copy must not be claimed as done") +} + +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) +} diff --git a/cmd/setup/model.go b/cmd/setup/model.go index 48e02fc2..78c602b6 100644 --- a/cmd/setup/model.go +++ b/cmd/setup/model.go @@ -1,6 +1,10 @@ package setup import ( + "io" + "os" + + "github.com/atotto/clipboard" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" @@ -13,6 +17,16 @@ import ( "github.com/launchdarkly/ldcli/internal/setup" ) +// copyState records how the visible snippet was copied, so the view can confirm a +// clipboard write outright but only claim to have asked when the terminal did it. +type copyState int + +const ( + copyNone copyState = iota + copyDone // written to the OS clipboard + copyRequested // handed to the terminal over OSC 52, which cannot confirm +) + type wizardStep int const ( @@ -70,6 +84,13 @@ type wizardModel struct { initResult *setup.InitResult verifyResult *setup.VerifyResult + // nativeCopy puts content on the operating system's clipboard, and clipboard + // receives the OSC 52 sequence used when that is not available. Both are fields + // so tests can drive either path without a real clipboard or terminal. + nativeCopy func(string) error + clipboard io.Writer + copyState copyState + quitting bool } @@ -119,6 +140,7 @@ type detectFailedMsg struct{} type installDoneMsg struct{ result *setup.InstallResult } type flagCreatedMsg struct{ key string } type initDoneMsg struct{ result *setup.InitResult } +type copiedMsg struct{ viaTerminal bool } type verifyDoneMsg struct{ result *setup.VerifyResult } type wizardErrMsg struct{ err error } @@ -143,8 +165,10 @@ func runSetupWizard( AccessToken: viper.GetString(cliflags.AccessTokenFlag), BaseURI: viper.GetString(cliflags.BaseURIFlag), }, - step: stepSelectProject, - spinner: s, + step: stepSelectProject, + spinner: s, + clipboard: os.Stdout, + nativeCopy: clipboard.WriteAll, } p := tea.NewProgram(m, tea.WithAltScreen()) diff --git a/cmd/setup/update.go b/cmd/setup/update.go index 0aa90d5a..79cf18f8 100644 --- a/cmd/setup/update.go +++ b/cmd/setup/update.go @@ -34,10 +34,26 @@ 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 + } + return m, m.copyToClipboard(content) case "enter": return m.handleEnter() } + case copiedMsg: + m.copyState = copyDone + if msg.viaTerminal { + m.copyState = copyRequested + } + return m, nil + case projectsFetchedMsg: m.projects = msg.projects items := make([]list.Item, len(msg.projects)) diff --git a/cmd/setup/view.go b/cmd/setup/view.go index b939d181..dc6f8344 100644 --- a/cmd/setup/view.go +++ b/cmd/setup/view.go @@ -11,6 +11,23 @@ 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 "" + } + switch m.copyState { + case copyDone: + return mutedStyle.Render(fmt.Sprintf("Copied the %s to your clipboard.", label)) + "\n" + case copyRequested: + return mutedStyle.Render(fmt.Sprintf("Asked your terminal to copy the %s.", label)) + "\n" + } + return mutedStyle.Render(fmt.Sprintf("Press c to copy the %s.", label)) + "\n" +} + func (m wizardModel) View() string { if m.quitting { return "" @@ -75,7 +92,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" @@ -88,7 +105,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 { diff --git a/go.mod b/go.mod index 46834766..8ca60f0c 100644 --- a/go.mod +++ b/go.mod @@ -4,10 +4,12 @@ go 1.24.3 require ( github.com/adrg/xdg v0.5.3 + github.com/atotto/clipboard v0.1.4 github.com/charmbracelet/bubbles v0.21.0 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 @@ -38,11 +40,9 @@ require ( require ( github.com/alecthomas/chroma/v2 v2.14.0 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect - github.com/atotto/clipboard v0.1.4 // indirect 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