Skip to content

Commit 504876e

Browse files
committed
improve service clone
1 parent c923919 commit 504876e

3 files changed

Lines changed: 167 additions & 21 deletions

File tree

tui/screens/services/model.go

Lines changed: 84 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ const (
3131
modeCreate
3232
modeEdit
3333
modeClone
34+
modeCloneCluster
3435
modeWorkbench
3536
)
3637

@@ -143,6 +144,9 @@ type Model struct {
143144
formDryRun bool
144145
wbTemplate bool
145146
confirmName string
147+
148+
pickingCloneDest bool
149+
cloneDest servicesbridge.Cluster
146150
}
147151

148152
type formField struct {
@@ -262,6 +266,8 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
262266
m.cursor = 0
263267
m.err = nil
264268
m.needsAuth = false
269+
m.pickingCloneDest = false
270+
m.cloneDest = servicesbridge.Cluster{}
265271
if m.loader == nil {
266272
m.loading = false
267273
return m, nil
@@ -277,7 +283,11 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
277283
if msg.err == nil {
278284
m.clusters = msg.clusters
279285
m.clusterCursor = clampCursor(m.clusterCursor, len(m.clusters))
280-
m.mode = modeClusters
286+
if m.pickingCloneDest {
287+
m.mode = modeCloneCluster
288+
} else {
289+
m.mode = modeClusters
290+
}
281291
}
282292
return m, nil
283293
case listedMsg:
@@ -374,6 +384,8 @@ func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) {
374384
return m.updateTarball(action, key)
375385
case modeCreate, modeEdit, modeClone:
376386
return m.updateForm(action, key)
387+
case modeCloneCluster:
388+
return m.updateCloneCluster(action)
377389
case modeWorkbench:
378390
return m.updateWorkbench(action, key, text)
379391
case modeDetail:
@@ -409,19 +421,26 @@ func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) {
409421
func (m Model) updateFilter(action keyAction, key tea.KeyPressMsg) (Model, tea.Cmd) {
410422
switch action {
411423
case keyActionBack:
412-
m.mode = modeList
413-
if m.filteringCluster {
424+
m.filterInput.Blur()
425+
if m.pickingCloneDest {
426+
m.mode = modeCloneCluster
427+
} else if m.filteringCluster {
414428
m.mode = modeClusters
429+
} else {
430+
m.mode = modeList
415431
}
416-
m.filterInput.Blur()
417432
return m, nil
418433
case keyActionConfirm:
419434
value := strings.TrimSpace(m.filterInput.Value())
420435
m.filterInput.Blur()
421-
if m.filteringCluster {
436+
if m.pickingCloneDest || m.filteringCluster {
422437
m.clusterFilter = value
423-
m.mode = modeClusters
424438
m.clusterCursor = 0
439+
if m.pickingCloneDest {
440+
m.mode = modeCloneCluster
441+
} else {
442+
m.mode = modeClusters
443+
}
425444
return m, m.beginClusters()
426445
}
427446
m.serviceFilter = value
@@ -495,7 +514,7 @@ func (m Model) openAction(a detailAction) (Model, tea.Cmd) {
495514
case actionEdit:
496515
return m.beginEditForm(), nil
497516
case actionClone:
498-
return m.beginCloneForm(), nil
517+
return m.beginClone()
499518
case actionWorkbench:
500519
m.mode = modeWorkbench
501520
m.wbTemplate = true
@@ -650,21 +669,63 @@ func (m Model) beginEditForm() Model {
650669
return m
651670
}
652671

672+
func (m Model) beginClone() (Model, tea.Cmd) {
673+
m.pickingCloneDest = true
674+
m.cloneDest = servicesbridge.Cluster{}
675+
m.clusterFilter = ""
676+
m.clusterCursor = 0
677+
m.err = nil
678+
m.mode = modeCloneCluster
679+
m.loading = true
680+
return m, m.beginClusters()
681+
}
682+
683+
func (m Model) updateCloneCluster(action keyAction) (Model, tea.Cmd) {
684+
switch action {
685+
case keyActionBack:
686+
m.pickingCloneDest = false
687+
m.clusterFilter = ""
688+
m.mode = modeDetail
689+
m.err = nil
690+
return m, nil
691+
case keyActionMoveUp:
692+
m.clusterCursor = clampCursor(m.clusterCursor-1, len(m.clusters))
693+
case keyActionMoveDown:
694+
m.clusterCursor = clampCursor(m.clusterCursor+1, len(m.clusters))
695+
case keyActionConfirm:
696+
if len(m.clusters) == 0 {
697+
return m, nil
698+
}
699+
m.cloneDest = m.clusters[m.clusterCursor]
700+
m.pickingCloneDest = false
701+
m.clusterFilter = ""
702+
return m.beginCloneForm(), nil
703+
case keyActionRefresh:
704+
return m, m.beginClusters()
705+
case keyActionFilter:
706+
m.mode = modeFilter
707+
m.filteringCluster = true
708+
m.filterInput.Placeholder = "filter destination clusters"
709+
m.filterInput.SetValue(m.clusterFilter)
710+
m.filterInput.Focus()
711+
m.formInput = m.filterInput
712+
}
713+
return m, nil
714+
}
715+
653716
func (m Model) beginCloneForm() Model {
654717
m.mode = modeClone
655718
m.formFields = []formField{
656-
{label: "Dest cluster ID", key: "cluster"},
657719
{label: "Name", key: "name"},
658720
{label: "Namespace", key: "namespace"},
659721
}
660722
m.formIndex = 0
661723
m.formValues = map[string]string{
662724
"name": m.detail.Name + "-clone",
663-
"namespace": m.detail.Namespace,
664-
"cluster": m.detail.ClusterID,
725+
"namespace": loCoalesce(m.detail.Namespace, "default"),
665726
}
666-
m.formInput.SetValue(m.formValues["cluster"])
667-
m.formInput.Placeholder = "destination cluster id"
727+
m.formInput.SetValue(m.formValues["name"])
728+
m.formInput.Placeholder = "cloned service name"
668729
m.formInput.Focus()
669730
m.err = nil
670731
return m
@@ -674,9 +735,13 @@ func (m Model) updateForm(action keyAction, key tea.KeyPressMsg) (Model, tea.Cmd
674735
switch action {
675736
case keyActionBack:
676737
m.formInput.Blur()
677-
if m.mode == modeCreate {
738+
switch m.mode {
739+
case modeCreate:
678740
m.mode = modeList
679-
} else {
741+
case modeClone:
742+
m.pickingCloneDest = true
743+
m.mode = modeCloneCluster
744+
default:
680745
m.mode = modeDetail
681746
}
682747
return m, nil
@@ -794,19 +859,20 @@ func (m Model) submitForm() (Model, tea.Cmd) {
794859
case modeClone:
795860
input := servicesbridge.CloneInput{
796861
SourceID: m.detail.ID,
797-
DestClusterID: m.formValues["cluster"],
862+
DestClusterID: m.cloneDest.ID,
798863
Name: m.formValues["name"],
799864
Namespace: m.formValues["namespace"],
800865
}
866+
dest := clusterLabel(m.cloneDest)
801867
m.pending = pendingOp{
802868
kind: actionClone,
803869
title: "Clone · " + m.detail.Name,
804-
cli: "plural cd services clone " + input.DestClusterID + " " + m.detail.ID + " --name " + input.Name,
870+
cli: fmt.Sprintf("plural cd services clone %s %s --name %s --namespace %s", dest, m.detail.ID, input.Name, input.Namespace),
805871
clone: &input,
806872
lines: []string{
807873
"Action Clone service",
808-
"Source " + m.detail.Name,
809-
"Dest " + input.DestClusterID,
874+
"Source " + m.detail.Name + " · " + clusterLabel(m.cluster),
875+
"Dest " + dest,
810876
"Name " + input.Name,
811877
"Namespace " + input.Namespace,
812878
},

tui/screens/services/model_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88

99
tea "charm.land/bubbletea/v2"
1010
"github.com/charmbracelet/colorprofile"
11+
"github.com/charmbracelet/x/ansi"
1112

1213
"github.com/pluralsh/plural-cli/pkg/bridge"
1314
servicesbridge "github.com/pluralsh/plural-cli/pkg/bridge/services"
@@ -187,6 +188,58 @@ func TestDeleteRequiresTypedName(t *testing.T) {
187188
}
188189
}
189190

191+
func TestClonePicksDestinationCluster(t *testing.T) {
192+
loader := &fakeLoader{
193+
clusters: []servicesbridge.Cluster{
194+
{ID: "c1", Name: "production", Handle: "prod-eu"},
195+
{ID: "c2", Name: "staging", Handle: "staging"},
196+
},
197+
page: servicesbridge.Page{Items: []servicesbridge.Summary{{ID: "1", Name: "api", Namespace: "default"}}},
198+
detail: servicesbridge.Detail{
199+
Summary: servicesbridge.Summary{ID: "1", Name: "api", Namespace: "default"},
200+
ClusterID: "c1", ClusterHandle: "prod-eu", ClusterName: "production",
201+
},
202+
}
203+
model := loadClusters(t, New(t.Context(), loader, theme.New(colorprofile.ASCII)))
204+
model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
205+
model, _ = model.Update(cmd())
206+
model, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
207+
model, _ = model.Update(cmd())
208+
209+
model, cmd = model.Update(tea.KeyPressMsg{Code: 'c', Text: "c"})
210+
if cmd == nil {
211+
t.Fatal("expected cluster reload for clone")
212+
}
213+
model, _ = model.Update(cmd())
214+
if model.mode != modeCloneCluster || !model.pickingCloneDest {
215+
t.Fatalf("clone cluster mode = %d picking=%v", model.mode, model.pickingCloneDest)
216+
}
217+
if !strings.Contains(model.View(80, 24), "Choose destination cluster") {
218+
t.Fatalf("missing destination picker:\n%s", model.View(80, 24))
219+
}
220+
if !strings.Contains(ansi.Strip(model.View(80, 24)), "(source)") {
221+
t.Fatalf("source cluster not marked:\n%s", ansi.Strip(model.View(80, 24)))
222+
}
223+
224+
model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyDown})
225+
model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
226+
if model.mode != modeClone || model.cloneDest.ID != "c2" {
227+
t.Fatalf("clone form dest = %#v mode=%d", model.cloneDest, model.mode)
228+
}
229+
model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) // accept name
230+
model.formInput.SetValue("default")
231+
model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) // accept namespace → review
232+
if model.mode != modeReview || model.pending.clone == nil || model.pending.clone.DestClusterID != "c2" {
233+
t.Fatalf("review = mode=%d pending=%#v", model.mode, model.pending)
234+
}
235+
if model.pending.clone.Name != "api-clone" {
236+
t.Fatalf("clone name = %q", model.pending.clone.Name)
237+
}
238+
if !strings.Contains(strings.Join(model.pending.lines, "\n"), "@staging") {
239+
t.Fatalf("review missing dest label: %#v", model.pending.lines)
240+
}
241+
}
242+
190243
func TestBackFromClustersReturnsDeployments(t *testing.T) {
191244
model := loadClusters(t, New(t.Context(), &fakeLoader{}, theme.New(colorprofile.ASCII)))
192245
_, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEsc})

tui/screens/services/view.go

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ func (m Model) View(width, height int) string {
3535
title = "Edit · " + m.detail.Name
3636
case modeClone:
3737
title = "Clone · " + m.detail.Name
38+
case modeCloneCluster:
39+
title = "Clone · choose destination"
3840
case modeWorkbench:
3941
title = "Workbench · " + m.detail.Name
4042
case modeDetail:
@@ -158,6 +160,12 @@ func (m Model) bodyAndHelp(width int) (string, string) {
158160
return page.Panel(m.theme, "Destination", lines, width, 7, true), "enter review · esc cancel"
159161
case modeCreate, modeEdit, modeClone:
160162
return m.formView(width)
163+
case modeCloneCluster:
164+
help := "↑/↓ select · enter use cluster · / filter · r refresh · esc detail"
165+
if width < 100 {
166+
help = "↑/↓ · enter · / filter · esc detail"
167+
}
168+
return page.Panel(m.theme, m.cloneClusterTitle(), m.clusterLines(width), width, 14, true), help
161169
case modeWorkbench:
162170
mode := "› Template (.liquid / .tpl) Lua engine"
163171
if !m.wbTemplate {
@@ -196,7 +204,14 @@ func (m Model) bodyAndHelp(width int) (string, string) {
196204
}
197205

198206
func (m Model) formView(width int) (string, string) {
199-
lines := make([]string, 0, len(m.formFields)+3)
207+
lines := make([]string, 0, len(m.formFields)+4)
208+
if m.mode == modeClone {
209+
lines = append(lines,
210+
m.theme.Muted.Render("Destination "+clusterLabel(m.cloneDest)),
211+
m.theme.Muted.Render("Source "+m.detail.Name+" · "+clusterLabel(m.cluster)),
212+
"",
213+
)
214+
}
200215
for i, field := range m.formFields {
201216
value := m.formValues[field.key]
202217
cursor := " "
@@ -206,12 +221,21 @@ func (m Model) formView(width int) (string, string) {
206221
}
207222
lines = append(lines, cursor+pad(field.label, 12)+" "+value)
208223
}
209-
lines = append(lines, "", fmt.Sprintf("Dry-run attribute %v (ctrl+d toggle)", m.formDryRun))
224+
if m.mode != modeClone {
225+
lines = append(lines, "", fmt.Sprintf("Dry-run attribute %v (ctrl+d toggle)", m.formDryRun))
226+
}
210227
step := fmt.Sprintf("field %d/%d", m.formIndex+1, len(m.formFields))
211-
help := "↑/↓ fields · enter next/review · esc cancel · " + step
228+
help := "↑/↓ fields · enter next/review · esc back · " + step
212229
return page.Panel(m.theme, "Form", lines, width, 12, true), help
213230
}
214231

232+
func (m Model) cloneClusterTitle() string {
233+
if m.clusterFilter != "" {
234+
return "Destination clusters · filter “" + m.clusterFilter + "”"
235+
}
236+
return "Choose destination cluster"
237+
}
238+
215239
func (m Model) actionLines(width int) []string {
216240
lines := make([]string, 0, len(detailActions()))
217241
for i, a := range detailActions() {
@@ -278,6 +302,9 @@ func (m Model) clusterLines(width int) []string {
278302
handle = "@" + handle
279303
}
280304
row := cursor + pad(handle, handleWidth) + " " + pad(cluster.Name, 24) + " " + cluster.ID
305+
if m.mode == modeCloneCluster && cluster.ID != "" && cluster.ID == m.detail.ClusterID {
306+
row += " " + m.theme.Muted.Render("(source)")
307+
}
281308
lines = append(lines, ansi.Truncate(row, width-2, "…"))
282309
}
283310
return lines

0 commit comments

Comments
 (0)