Skip to content

Commit aeaf3a4

Browse files
authored
refactor(ui): action-first flow with immediate save (#2340)
## Summary Replaces #2335 (closed). Combines the action-first UI restructure with immediate save and UX improvements. ### From #2335 (base changes) - Restructure overview to action-first flow (Create, Reconfigure, Promote, Quit) - Grouped bundle selection with detail boxes and environment filters - Scrollable panels, breadcrumb navigation, keyboard shortcuts - Cloud login prompt, error dialogs, nested bundle creation ### New in this PR - **Immediate save**: Reconfigure and Promote save on confirm (like Create), removing pending changes batch-save entirely - **Session panel**: Overview shows "Recently Changed" with grouped bundles, change kind tags, and direct reconfigure - **Unified input form**: Single list with inline diff — no Changed/Unchanged section split - **Immutable inputs**: Blue values, "immutable" tag, cursor skips them - **Button flow**: Save/Cancel at bottom, Back when no changes (reconfig); left/right + up/down navigation - **ESC handling**: Discard confirmation when reconfig has unsaved changes - **Registry cleanup**: Removed `ui.Registry` wrapper, use `*config.Registry` directly - **Type system fix**: Moved bundle-ref object→alias extraction from `BundleType.Apply` to UI layer - **Upfront validation**: `checkBundleRefsResolved()` replaces brittle string-matching error detection - **Styling**: Underlined hotkey letters, env tags in breadcrumbs, last-saved highlight - **Dead code removed**: view_edit.go, PendingChanges/ProposedChanges, SavedChange, and many unused fields ## Test plan - [ ] Create: saves immediately, appears in session panel and reconfigure list - [ ] Reconfigure: saves immediately, session panel shows "reconfigured" tag - [ ] Promote: saves immediately, bundle appears in target env - [ ] Session panel: shows only changed envs, dimmed when unfocused, Enter reconfigures - [ ] Reconfig form: immutable fields skipped, changed values orange with diff - [ ] Reconfig form: cursor returns to edited item, Save/Cancel at bottom - [ ] Reconfig form: ESC with changes triggers discard confirmation - [ ] Promote form: Save always available, ESC goes back without confirmation - [ ] Quit exits immediately (no unsaved changes warning) - [ ] Bundle references resolve correctly after immediate save (nested bundles) 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes core TUI workflow to write bundle instance YAML immediately (create/reconfigure/promote) and reload the registry in-session, so mistakes can persist to disk and affect subsequent resolution/filtering. UI navigation/rendering was substantially reworked, increasing risk of UX regressions across create/reconfig/promote flows. > > **Overview** > Refactors the TUI to an **action-first flow** and removes the pending/batch save model: Create/Reconfigure/Promote now **save bundle instance YAML immediately**, reload the `config.Registry` after each save, and the overview shows a **“Recently Changed”** session panel that can jump straight into reconfigure. > > Create selection is redesigned into a **flat, sorted bundle list** with a detail box, and Create adds a **deferred environment picker** when the chosen bundle requires an environment. Reconfigure/Promote selection adds **environment-based filtering** (cycle with `e`), richer detail boxes, and updated breadcrumbs/hotkeys. > > `InputsForm` is unified into a single ordered list (no Changed/Unchanged split) with inline diffs, improved cursor/navigation (skips immutable/disabled inputs, wraps), and updated button logic (Save/Cancel; Back when no reconfig changes; ESC discard confirmation for reconfig). Bundle-ref handling is hardened by normalizing stored object values back to alias strings, rebinding `bundle()`/`bundles()` to the live registry during change evaluation, and adding `checkBundleRefsResolved()` for clearer missing-dependency errors. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 0a8f97f. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
2 parents ee0f51c + 0a8f97f commit aeaf3a4

15 files changed

Lines changed: 1788 additions & 1638 deletions

commands/ui/change.go

Lines changed: 78 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,12 @@ import (
2323
"github.com/terramate-io/terramate/hcl"
2424
"github.com/terramate-io/terramate/hcl/ast"
2525
"github.com/terramate-io/terramate/project"
26+
"github.com/terramate-io/terramate/stdlib"
2627
"github.com/terramate-io/terramate/typeschema"
2728
"github.com/terramate-io/terramate/yaml"
2829
)
2930

30-
// ChangeKind indicates the type of pending change.
31+
// ChangeKind indicates the type of change.
3132
type ChangeKind string
3233

3334
// ChangeCreate and the following constants enumerate the supported change kinds.
@@ -37,7 +38,7 @@ const (
3738
ChangePromote ChangeKind = "change_promote"
3839
)
3940

40-
// Change represents a pending change in the summary.
41+
// Change represents a bundle change (create, reconfigure, or promote).
4142
type Change struct {
4243
Kind ChangeKind
4344
HostPath string
@@ -62,21 +63,6 @@ type Change struct {
6263

6364
Warnings []string // Non-fatal warnings surfaced after resolving the change
6465

65-
// Simple incremental ID returned to the LLM so it can track this change reliably.
66-
ProposalID int
67-
68-
// Set to exclude from uniqueness checks, so an edited change doesn't conflict with itself.
69-
MarkedForReplacement bool
70-
}
71-
72-
// SavedChange is a lightweight summary of a change that was persisted to disk.
73-
type SavedChange struct {
74-
Kind ChangeKind
75-
Name string
76-
EnvID string
77-
FromEnvID string
78-
ProjectPath string
79-
HostPath string
8066
}
8167

8268
// NewCreateChange builds a Change that represents a new bundle creation.
@@ -90,6 +76,12 @@ func NewCreateChange(
9076
) (Change, error) {
9177
schemactx = schemactx.ChildContext()
9278

79+
// Rebind bundle() functions to the current registry so that references
80+
// to bundles created during this session (e.g. nested bundles that were
81+
// saved immediately) are resolvable.
82+
schemactx.Evalctx.SetFunction(stdlib.Name("bundle"), config.BundleFunc(est.Context, est.Registry, activeEnv, false))
83+
schemactx.Evalctx.SetFunction(stdlib.Name("bundles"), config.BundlesFunc(est.Registry, activeEnv))
84+
9385
// The form may or may not contain values for all defaults.
9486
// In this step we re-run input evaluation like it would be done if this was a bundle instance that
9587
// has the form values as inputs. This will ensure we get all the inputs.
@@ -102,6 +94,9 @@ func NewCreateChange(
10294
if err != nil {
10395
return Change{}, err
10496
}
97+
if err := checkBundleRefsResolved(inputDefs, allValues); err != nil {
98+
return Change{}, err
99+
}
105100

106101
// We check if the bundle has an explicit alias and add it to the context if yes.
107102
alias, err := setupExplicitBundleAlias(schemactx.Evalctx, bde.Define)
@@ -156,7 +151,7 @@ func NewCreateChange(
156151
}
157152

158153
// Final check: Is the bundle unique?
159-
if err := est.Registry.IsBundleUnique(alias, bde.Metadata.Class, hostPath, env); err != nil {
154+
if err := IsBundleUnique(est.Registry, alias, bde.Metadata.Class, hostPath, env); err != nil {
160155
return Change{}, err
161156
}
162157

@@ -188,6 +183,11 @@ func NewReconfigChange(
188183
) (Change, error) {
189184
schemactx = schemactx.ChildContext()
190185

186+
// Rebind bundle() functions to the current registry so that references
187+
// to bundles created/reconfigured during this session are resolvable.
188+
schemactx.Evalctx.SetFunction(stdlib.Name("bundle"), config.BundleFunc(est.Context, est.Registry, bundle.Environment, false))
189+
schemactx.Evalctx.SetFunction(stdlib.Name("bundles"), config.BundlesFunc(est.Registry, bundle.Environment))
190+
191191
hostPath := bundle.Info.HostPath()
192192
projPath := project.PrjAbsPath(est.Root.HostDir(), hostPath).String()
193193

@@ -203,6 +203,9 @@ func NewReconfigChange(
203203
if err != nil {
204204
return Change{}, err
205205
}
206+
if err := checkBundleRefsResolved(inputDefs, allValues); err != nil {
207+
return Change{}, err
208+
}
206209

207210
// This will only be set if there is an explicit alias.
208211
newAlias, err := setupExplicitBundleAlias(schemactx.Evalctx, bde.Define)
@@ -258,6 +261,11 @@ func NewPromoteChange(
258261
) (Change, error) {
259262
schemactx = schemactx.ChildContext()
260263

264+
// Rebind bundle() functions to the current registry so that references
265+
// to bundles promoted during this session are resolvable.
266+
schemactx.Evalctx.SetFunction(stdlib.Name("bundle"), config.BundleFunc(est.Context, est.Registry, env, false))
267+
schemactx.Evalctx.SetFunction(stdlib.Name("bundles"), config.BundlesFunc(est.Registry, env))
268+
261269
hostPath := bundle.Info.HostPath()
262270
projPath := project.PrjAbsPath(est.Root.HostDir(), hostPath).String()
263271

@@ -273,6 +281,9 @@ func NewPromoteChange(
273281
if err != nil {
274282
return Change{}, err
275283
}
284+
if err := checkBundleRefsResolved(inputDefs, allValues); err != nil {
285+
return Change{}, err
286+
}
276287

277288
// This will only be set if there is an explicit alias.
278289
newAlias, err := setupExplicitBundleAlias(schemactx.Evalctx, bde.Define)
@@ -317,48 +328,6 @@ func NewPromoteChange(
317328
}, nil
318329
}
319330

320-
// NewChangeFromExisting rebuilds a Change from a previously created change with updated values.
321-
func NewChangeFromExisting(
322-
est *EngineState,
323-
oldChange Change,
324-
schemactx typeschema.EvalContext,
325-
inputDefs []*config.InputDefinition,
326-
values map[string]cty.Value,
327-
) (Change, error) {
328-
switch oldChange.Kind {
329-
case ChangeCreate:
330-
return NewCreateChange(
331-
est,
332-
oldChange.Env,
333-
oldChange.BundleDefEntry,
334-
schemactx,
335-
inputDefs,
336-
values,
337-
)
338-
case ChangeReconfig:
339-
return NewReconfigChange(
340-
est,
341-
oldChange.OriginalBundle,
342-
oldChange.BundleDefEntry,
343-
schemactx,
344-
inputDefs,
345-
values,
346-
)
347-
case ChangePromote:
348-
return NewPromoteChange(
349-
est,
350-
oldChange.Env,
351-
oldChange.OriginalBundle,
352-
oldChange.BundleDefEntry,
353-
schemactx,
354-
inputDefs,
355-
values,
356-
)
357-
default:
358-
panic("unsupported ChangeKind " + oldChange.Kind)
359-
}
360-
}
361-
362331
// reEvalAllInputs evaluates the bundle's input definitions using the prompted
363332
// values as a simulated inputs block, filling in defaults for any inputs that
364333
// were not prompted.
@@ -415,6 +384,47 @@ func reEvalAllInputs(
415384
return result, nil
416385
}
417386

387+
// normalizeBundleRefValues converts resolved bundle objects back to alias strings.
388+
// When reconfiguring or promoting, bundle-ref inputs are loaded as full objects
389+
// (with alias, uuid, etc.) from disk. The type system expects strings, so we
390+
// extract the alias before the values enter the form.
391+
func normalizeBundleRefValues(inputDefs []*config.InputDefinition, values map[string]cty.Value) map[string]cty.Value {
392+
for _, def := range inputDefs {
393+
if _, isBundleType := def.Type.(*typeschema.BundleType); !isBundleType {
394+
continue
395+
}
396+
v, ok := values[def.Name]
397+
if !ok || v == cty.NilVal || v.IsNull() || !v.IsKnown() {
398+
continue
399+
}
400+
if v.Type().IsObjectType() && v.Type().HasAttribute("alias") {
401+
alias := v.GetAttr("alias")
402+
if alias.IsKnown() && alias.Type() == cty.String {
403+
values[def.Name] = alias
404+
}
405+
}
406+
}
407+
return values
408+
}
409+
410+
// checkBundleRefsResolved verifies that all bundle-ref inputs resolved to non-null
411+
// values. Returns a user-friendly error if any referenced bundle is missing.
412+
func checkBundleRefsResolved(inputDefs []*config.InputDefinition, values map[string]cty.Value) error {
413+
for _, def := range inputDefs {
414+
if _, isBundleType := def.Type.(*typeschema.BundleType); !isBundleType {
415+
continue
416+
}
417+
v, ok := values[def.Name]
418+
if !ok || v == cty.NilVal {
419+
continue
420+
}
421+
if v.IsNull() {
422+
return errors.E("Input %q references a bundle that does not exist in this environment. Promote dependencies first.", def.Name)
423+
}
424+
}
425+
return nil
426+
}
427+
418428
// Save writes the change to disk as a YAML bundle instance file.
419429
func (c *Change) Save(envs []*config.Environment) error {
420430
var existing *yaml.BundleInstance
@@ -449,6 +459,14 @@ func (c *Change) generateBundleYAML(existing *yaml.BundleInstance, envs []*confi
449459
continue
450460
}
451461

462+
// Bundle-ref values are stored as resolved objects internally
463+
// but must be written as alias strings in the YAML config.
464+
if _, isBundleType := def.Type.(*typeschema.BundleType); isBundleType {
465+
if v.IsKnown() && !v.IsNull() && v.Type().IsObjectType() && v.Type().HasAttribute("alias") {
466+
v = v.GetAttr("alias")
467+
}
468+
}
469+
452470
yv, err := yaml.ConvertFromCty(v)
453471
if err != nil {
454472
return "", err

0 commit comments

Comments
 (0)