From cec6f98ddd94ce679c3da98d388b721be41c1f6f Mon Sep 17 00:00:00 2001 From: Naman-Nirwan Date: Fri, 4 Sep 2026 03:12:09 +0530 Subject: [PATCH 01/13] just having a centeralised auth override function --- pkg/auth/auth.go | 17 +++++++++++++++++ pkg/registry/buildctx.go | 8 ++------ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go index e5c899a6..5a21b081 100644 --- a/pkg/auth/auth.go +++ b/pkg/auth/auth.go @@ -162,6 +162,23 @@ func Resolve(profileFlag string) (*ResolvedAuth, error) { return r, nil } +// ResolveWithOverrides resolves credentials via Resolve, then applies orgOverride/ +// projectOverride on top of whatever the resolved profile/env already set. An empty +// override leaves the resolved value untouched. +func ResolveWithOverrides(profileFlag, orgOverride, projectOverride string) (*ResolvedAuth, error) { + r, err := Resolve(profileFlag) + if err != nil { + return nil, err + } + if orgOverride != "" { + r.OrgID = orgOverride + } + if projectOverride != "" { + r.ProjectID = projectOverride + } + return r, nil +} + func resolveProfile(name string) (*ResolvedAuth, error) { cfg, err := config.LoadConfig() if err != nil { diff --git a/pkg/registry/buildctx.go b/pkg/registry/buildctx.go index 06703eca..c03bcc4a 100644 --- a/pkg/registry/buildctx.go +++ b/pkg/registry/buildctx.go @@ -62,12 +62,10 @@ func (r *Registry) buildCompletionCtx(cmd *cobra.Command, verb, noun, parentId s profileFlag, _ := cmd.Flags().GetString("profile") orgFlag, _ := cmd.Flags().GetString("org") projectFlag, _ := cmd.Flags().GetString("project") - resolved, err := auth.Resolve(profileFlag) + resolved, err := auth.ResolveWithOverrides(profileFlag, orgFlag, projectFlag) if err != nil { return nil, err } - resolved.OrgID = firstNonEmpty(orgFlag, resolved.OrgID) - resolved.ProjectID = firstNonEmpty(projectFlag, resolved.ProjectID) ctx, cancel := context.WithCancelCause(context.Background()) go runTimeout(completionTimeout, cancel) @@ -247,12 +245,10 @@ func buildCtx(cmd *cobra.Command, cs *spec.CommandSpec, args []string, r *Regist profileFlag, _ := cmd.Flags().GetString("profile") orgFlag, _ := cmd.Flags().GetString("org") projectFlag, _ := cmd.Flags().GetString("project") - resolved, err := auth.Resolve(profileFlag) + resolved, err := auth.ResolveWithOverrides(profileFlag, orgFlag, projectFlag) if err != nil { return nil, err } - resolved.OrgID = firstNonEmpty(orgFlag, resolved.OrgID) - resolved.ProjectID = firstNonEmpty(projectFlag, resolved.ProjectID) ctx.Auth = resolved } if cs.HasArgs { From 99e3d06f55b41f3ebb7ae3f6548240b434404247 Mon Sep 17 00:00:00 2001 From: Naman-Nirwan Date: Fri, 4 Sep 2026 03:41:53 +0530 Subject: [PATCH 02/13] new object for the UILink, a detailed one with cases --- pkg/cmdctx/uilink.go | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 pkg/cmdctx/uilink.go diff --git a/pkg/cmdctx/uilink.go b/pkg/cmdctx/uilink.go new file mode 100644 index 00000000..e5fdf77e --- /dev/null +++ b/pkg/cmdctx/uilink.go @@ -0,0 +1,33 @@ +// Copyright © 2026 Harness Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmdctx + +// UIScreenKind identifies which screen function should be called to redraw a +// UILink: a browse table scoped to a parent id, a browse table scoped to a +// parent id that itself came from a "get" lookup, or a detail-only overlay. +type UIScreenKind int + +const ( + ScreenTable UIScreenKind = iota + ScreenTableForGet + ScreenDetailForGet +) + +// UILink is a replayable reference to a --ui Hop's target: enough to rebuild +// the Ctx for that screen and redraw it, without encoding anything onto a +// command line. Profile/Org/Project are the raw scope flag strings in effect +// at the Hop (not a cached resolved Auth), so replay can re-resolve auth +// instead of inheriting whatever was live in memory when the Link was pushed. +type UILink struct { + Verb, Noun string + Id string + IdParts []string + Level string + + Profile, Org, Project string + FlagValues map[string]any + + Screen UIScreenKind + ListPos int +} From bf7edc8e1c3171bf7a3190b939b90664bc91c1d7 Mon Sep 17 00:00:00 2001 From: Naman-Nirwan Date: Fri, 4 Sep 2026 04:03:07 +0530 Subject: [PATCH 03/13] build the UILink object for the wiring and specs --- pkg/registry/uitableview.go | 55 +++++++++++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/pkg/registry/uitableview.go b/pkg/registry/uitableview.go index 1c0d802a..e1fafbe8 100644 --- a/pkg/registry/uitableview.go +++ b/pkg/registry/uitableview.go @@ -106,8 +106,8 @@ type uiTableModel struct { activeTextKey string // key of the ui_commands text entry currently rendered in detail printOnExit []string launchUIId string - launchUIHandlerFn string // view entry's ui_handler_fn, set on quit - linkTarget *uiLinkTarget // link entry to follow, set on quit + launchUIHandlerFn string // view entry's ui_handler_fn, set on quit + linkTarget *cmdctx.UILink // link entry to follow, set on quit // picker mode — set via newUIPickerModel; enter selects and quits pickerMode bool @@ -494,6 +494,37 @@ func (m uiTableModel) activeDetailCs() *spec.CommandSpec { return m.getCs } +// scopeFromCtx extracts the raw profile/org/project scope in effect on ctx, for +// stashing on a UILink so replay can re-resolve auth instead of inheriting whatever +// Auth pointer happens to be live in memory. Safe when ctx.Auth is nil (NoAuth). +func scopeFromCtx(ctx *cmdctx.Ctx) (profile, org, project string) { + if ctx.Auth == nil { + return "", "", "" + } + return ctx.Auth.ExplicitProfile, ctx.Auth.OrgID, ctx.Auth.ProjectID +} + +// buildUILink assembles a UILink for a link/up ui_commands hop: captures the scope in +// effect on m.ctx and picks which screen fn will redraw it based on the target verb. +func (m uiTableModel) buildUILink(verb, noun, id string) *cmdctx.UILink { + profile, org, project := scopeFromCtx(m.ctx) + screen := cmdctx.ScreenDetailForGet + if verb == VerbList { + screen = cmdctx.ScreenTable + } + return &cmdctx.UILink{ + Verb: verb, + Noun: noun, + Id: id, + Level: m.ctx.Level, + Profile: profile, + Org: org, + Project: project, + FlagValues: m.ctx.FlagValues, + Screen: screen, + } +} + // dispatchUICommandKey handles a hotkey press against the active noun's // ui_commands list: re-renders in place for a text entry, or queues a view/link // hand-off and quits for view/link entries. handled=false means key isn't bound. @@ -516,7 +547,7 @@ func (m uiTableModel) dispatchUICommandKey(key string) (uiTableModel, tea.Cmd, b m.launchUIHandlerFn = uc.UIHandlerFn return m, tea.Quit, true case spec.UICommandLink: - m.linkTarget = &uiLinkTarget{verb: uc.Verb, noun: uc.Noun, id: m.detail.id} + m.linkTarget = m.buildUILink(uc.Verb, uc.Noun, m.detail.id) return m, tea.Quit, true case spec.UICommandUp: id := m.detail.upIds[uc.Key] @@ -530,7 +561,7 @@ func (m uiTableModel) dispatchUICommandKey(key string) (uiTableModel, tea.Cmd, b if verb == "" { verb = VerbGet } - m.linkTarget = &uiLinkTarget{verb: verb, noun: uc.Noun, id: id} + m.linkTarget = m.buildUILink(verb, uc.Noun, id) return m, tea.Quit, true } } @@ -1259,23 +1290,23 @@ func RunUIDetailForGet(ctx *cmdctx.Ctx, cs *spec.CommandSpec) error { // dispatchUILink follows a link-type ui_commands entry once the overlay quits: // a "list" target opens that noun's browse overlay scoped to the current id as // parent; a "get" target opens Case 4's detail-only overlay directly on the id. -func dispatchUILink(ctx *cmdctx.Ctx, lt *uiLinkTarget) error { - targetCs := ctx.Resolver.GetSpec(lt.verb, lt.noun) +func dispatchUILink(ctx *cmdctx.Ctx, lt *cmdctx.UILink) error { + targetCs := ctx.Resolver.GetSpec(lt.Verb, lt.Noun) if targetCs == nil { - return fmt.Errorf("ui_commands link target %s %q not found", lt.verb, lt.noun) + return fmt.Errorf("ui_commands link target %s %q not found", lt.Verb, lt.Noun) } - switch lt.verb { + switch lt.Verb { case VerbList: if targetCs.Endpoint == nil { - return fmt.Errorf("ui_commands link target %s %q has no endpoint", lt.verb, lt.noun) + return fmt.Errorf("ui_commands link target %s %q has no endpoint", lt.Verb, lt.Noun) } listCtx := buildPickerCtx(ctx, targetCs) - listCtx.ParentId = lt.id + listCtx.ParentId = lt.Id return RunUITable(listCtx, targetCs.Endpoint) case VerbGet: - getCtx := buildDetailCtx(ctx, targetCs, lt.id) + getCtx := buildDetailCtx(ctx, targetCs, lt.Id) return RunUIDetailForGet(getCtx, targetCs) default: - return fmt.Errorf("ui_commands link target %s %q: unsupported verb", lt.verb, lt.noun) + return fmt.Errorf("ui_commands link target %s %q: unsupported verb", lt.Verb, lt.Noun) } } From aa329d1e6558bfce4dda764d43eb6dad264336de Mon Sep 17 00:00:00 2001 From: Naman-Nirwan Date: Fri, 4 Sep 2026 04:25:18 +0530 Subject: [PATCH 04/13] Screen aspect for differentiating between the table and detail view of tea.program() --- pkg/registry/buildctx.go | 44 +++++++++++++++++++++++++++++++++++ pkg/registry/uitableview.go | 46 +++++++++++++++---------------------- 2 files changed, 62 insertions(+), 28 deletions(-) diff --git a/pkg/registry/buildctx.go b/pkg/registry/buildctx.go index c03bcc4a..65c4bedd 100644 --- a/pkg/registry/buildctx.go +++ b/pkg/registry/buildctx.go @@ -328,6 +328,50 @@ func buildDetailCtx(parent *cmdctx.Ctx, cs *spec.CommandSpec, id string) *cmdctx return ctx } +// buildLinkCtx constructs the Ctx for a replayed UILink — either a forward hop +// (link/up/view) or a popped History entry. Unlike buildDetailCtx/buildPickerCtx, which +// inherit the caller's already-resolved Auth verbatim, this re-resolves auth from the +// Link's raw profile/org/project strings, so a Link that captured a different scope +// replays with that scope instead of whatever happens to be live in the caller's ctx. +func buildLinkCtx(ctx *cmdctx.Ctx, link *cmdctx.UILink, targetCs *spec.CommandSpec) (*cmdctx.Ctx, error) { + var resolved *auth.ResolvedAuth + if !targetCs.NoAuth { + var err error + resolved, err = auth.ResolveWithOverrides(link.Profile, link.Org, link.Project) + if err != nil { + return nil, err + } + } + fv := link.FlagValues + if fv == nil { + fv = map[string]any{} + } + goCtx, cancel := context.WithCancelCause(ctx.Context) + newCtx := &cmdctx.Ctx{ + Context: goCtx, + CancelFn: cancel, + Auth: resolved, + Verb: targetCs.Verb, + VerbHandler: targetCs.VerbHandler, + Noun: targetCs.Noun, + FieldsNoun: targetCs.FieldsNoun, + Level: link.Level, + IsPty: ctx.IsPty, + Resolver: ctx.Resolver, + FormatFlags: cmdctx.FormatFlags{Format: "text"}, + FlagValues: fv, + } + if link.Screen == cmdctx.ScreenDetailForGet { + newCtx.Id = link.Id + if targetCs.IdParts > 1 { + newCtx.IdParts = strings.SplitN(link.Id, "/", targetCs.IdParts) + } + } else { + newCtx.ParentId = link.Id + } + return newCtx, nil +} + // resolveFlagValues runs any flag_resolve_fn declared on spec flags, overwriting // the raw string value in ctx.FlagValues with the resolved result. Skips flags // whose value is empty. Called after buildFlagValues and auth resolution. diff --git a/pkg/registry/uitableview.go b/pkg/registry/uitableview.go index e1fafbe8..2b21d2ea 100644 --- a/pkg/registry/uitableview.go +++ b/pkg/registry/uitableview.go @@ -38,14 +38,6 @@ type uiDetailModel struct { upIds map[string]string } -// uiLinkTarget carries a resolved link-type ui_commands entry to follow once -// the bubbletea program exits. -type uiLinkTarget struct { - verb string - noun string - id string -} - // uiDetailMsg is sent when a background detail fetch completes. type uiDetailMsg struct { content string @@ -1238,7 +1230,7 @@ func finishUIExit(ctx *cmdctx.Ctx, fm uiTableModel) error { fmt.Println(strings.Join(fm.printOnExit, "\n")) } if fm.linkTarget != nil { - return dispatchUILink(ctx, fm.linkTarget) + return dispatchLink(ctx, fm.linkTarget) } if fm.launchUIId != "" && fm.launchUIHandlerFn != "" { ctx.Id = fm.launchUIId @@ -1287,26 +1279,24 @@ func RunUIDetailForGet(ctx *cmdctx.Ctx, cs *spec.CommandSpec) error { return finishUIExit(ctx, fm) } -// dispatchUILink follows a link-type ui_commands entry once the overlay quits: -// a "list" target opens that noun's browse overlay scoped to the current id as -// parent; a "get" target opens Case 4's detail-only overlay directly on the id. -func dispatchUILink(ctx *cmdctx.Ctx, lt *cmdctx.UILink) error { - targetCs := ctx.Resolver.GetSpec(lt.Verb, lt.Noun) +// dispatchLink follows a Link once the overlay quits: builds a fresh Ctx via +// buildLinkCtx and redraws the target according to its Screen — ScreenDetailForGet +// opens Case 4's detail-only overlay directly on the id; anything else opens that +// noun's browse overlay scoped to the link's id as parent. +func dispatchLink(ctx *cmdctx.Ctx, link *cmdctx.UILink) error { + targetCs := ctx.Resolver.GetSpec(link.Verb, link.Noun) if targetCs == nil { - return fmt.Errorf("ui_commands link target %s %q not found", lt.Verb, lt.Noun) + return fmt.Errorf("ui_commands link target %s %q not found", link.Verb, link.Noun) } - switch lt.Verb { - case VerbList: - if targetCs.Endpoint == nil { - return fmt.Errorf("ui_commands link target %s %q has no endpoint", lt.Verb, lt.Noun) - } - listCtx := buildPickerCtx(ctx, targetCs) - listCtx.ParentId = lt.Id - return RunUITable(listCtx, targetCs.Endpoint) - case VerbGet: - getCtx := buildDetailCtx(ctx, targetCs, lt.Id) - return RunUIDetailForGet(getCtx, targetCs) - default: - return fmt.Errorf("ui_commands link target %s %q: unsupported verb", lt.Verb, lt.Noun) + if link.Screen != cmdctx.ScreenDetailForGet && targetCs.Endpoint == nil { + return fmt.Errorf("ui_commands link target %s %q has no endpoint", link.Verb, link.Noun) + } + linkCtx, err := buildLinkCtx(ctx, link, targetCs) + if err != nil { + return err + } + if link.Screen == cmdctx.ScreenDetailForGet { + return RunUIDetailForGet(linkCtx, targetCs) } + return RunUITable(linkCtx, targetCs.Endpoint) } From f422d72cd07b8dd915b86493aa62a8db08a5dd89 Mon Sep 17 00:00:00 2001 From: Naman-Nirwan Date: Fri, 4 Sep 2026 04:55:06 +0530 Subject: [PATCH 05/13] stack added to the command ctx and push/pop --- pkg/cmdctx/cmdctx.go | 3 +++ pkg/cmdctx/uilink.go | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/pkg/cmdctx/cmdctx.go b/pkg/cmdctx/cmdctx.go index 5ee53544..963547c3 100644 --- a/pkg/cmdctx/cmdctx.go +++ b/pkg/cmdctx/cmdctx.go @@ -231,6 +231,9 @@ type Ctx struct { // - "list-fields" bool when the flag exists (get/update commands) // - "profile", "org", "project" string when no_auth: true (the handler owns auth resolution) FlagValues map[string]any + // UIHistory is the --ui back-navigation stack: one UILink pushed per Hop + // (link/up/view), popped by the "b" key. Session-lifetime only. + UIHistory []UILink } // ScopedAuth returns Auth adjusted for Level: "org" clears ProjectID, "account" diff --git a/pkg/cmdctx/uilink.go b/pkg/cmdctx/uilink.go index e5fdf77e..fa52e6df 100644 --- a/pkg/cmdctx/uilink.go +++ b/pkg/cmdctx/uilink.go @@ -31,3 +31,21 @@ type UILink struct { Screen UIScreenKind ListPos int } + +// PushUILink appends link to the back-navigation stack (LIFO — PopUILink pops +// from the end). +func (c *Ctx) PushUILink(link UILink) { + c.UIHistory = append(c.UIHistory, link) +} + +// PopUILink removes and returns the most recently pushed UILink. ok is false +// when the stack is empty, in which case link is the zero value. +func (c *Ctx) PopUILink() (link UILink, ok bool) { + n := len(c.UIHistory) + if n == 0 { + return UILink{}, false + } + link = c.UIHistory[n-1] + c.UIHistory = c.UIHistory[:n-1] + return link, true +} From c12363348d379ecac927d283aae6051cd099497f Mon Sep 17 00:00:00 2001 From: Naman-Nirwan Date: Fri, 4 Sep 2026 04:57:02 +0530 Subject: [PATCH 06/13] some unit tests for the structure to test the stack --- pkg/cmdctx/uilink_test.go | 54 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 pkg/cmdctx/uilink_test.go diff --git a/pkg/cmdctx/uilink_test.go b/pkg/cmdctx/uilink_test.go new file mode 100644 index 00000000..23b91c5a --- /dev/null +++ b/pkg/cmdctx/uilink_test.go @@ -0,0 +1,54 @@ +// Copyright © 2026 Harness Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmdctx + +import "testing" + +func TestPushPopUILink_RoundTrip(t *testing.T) { + c := &Ctx{} + c.PushUILink(UILink{Verb: "get", Noun: "pr", Id: "1"}) + + link, ok := c.PopUILink() + if !ok { + t.Fatal("PopUILink: ok = false, want true") + } + if link.Verb != "get" || link.Noun != "pr" || link.Id != "1" { + t.Fatalf("PopUILink = %+v, want Verb=get Noun=pr Id=1", link) + } + if len(c.UIHistory) != 0 { + t.Fatalf("UIHistory len = %d, want 0 after pop", len(c.UIHistory)) + } +} + +func TestPopUILink_Empty(t *testing.T) { + c := &Ctx{} + link, ok := c.PopUILink() + if ok { + t.Fatal("PopUILink on empty stack: ok = true, want false") + } + if link.Verb != "" || link.Noun != "" || link.Id != "" { + t.Fatalf("PopUILink on empty stack: link = %+v, want zero value", link) + } +} + +func TestPushPopUILink_LIFOOrder(t *testing.T) { + c := &Ctx{} + c.PushUILink(UILink{Noun: "first"}) + c.PushUILink(UILink{Noun: "second"}) + c.PushUILink(UILink{Noun: "third"}) + + want := []string{"third", "second", "first"} + for _, w := range want { + link, ok := c.PopUILink() + if !ok { + t.Fatalf("PopUILink: ok = false, want true (expected %q)", w) + } + if link.Noun != w { + t.Fatalf("PopUILink.Noun = %q, want %q", link.Noun, w) + } + } + if _, ok := c.PopUILink(); ok { + t.Fatal("PopUILink after draining stack: ok = true, want false") + } +} From b2c27dbfa1d2c35ffba05ac06bdd7c8341a5cb84 Mon Sep 17 00:00:00 2001 From: Naman-Nirwan Date: Fri, 4 Sep 2026 05:03:33 +0530 Subject: [PATCH 07/13] passing on the History stack --- pkg/registry/buildctx.go | 2 ++ pkg/registry/buildctx_workflow_test.go | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/pkg/registry/buildctx.go b/pkg/registry/buildctx.go index 65c4bedd..63bb4904 100644 --- a/pkg/registry/buildctx.go +++ b/pkg/registry/buildctx.go @@ -318,6 +318,7 @@ func buildDetailCtx(parent *cmdctx.Ctx, cs *spec.CommandSpec, id string) *cmdctx Resolver: parent.Resolver, FormatFlags: cmdctx.FormatFlags{Format: "text"}, FlagValues: map[string]any{}, + UIHistory: parent.UIHistory, } // Endpoint path templates split ctx.Id into idParts on the fly (see exprenv.Make), but // workflow handlers that read the ctx.IdParts struct field directly (e.g. a multi-part @@ -360,6 +361,7 @@ func buildLinkCtx(ctx *cmdctx.Ctx, link *cmdctx.UILink, targetCs *spec.CommandSp Resolver: ctx.Resolver, FormatFlags: cmdctx.FormatFlags{Format: "text"}, FlagValues: fv, + UIHistory: ctx.UIHistory, } if link.Screen == cmdctx.ScreenDetailForGet { newCtx.Id = link.Id diff --git a/pkg/registry/buildctx_workflow_test.go b/pkg/registry/buildctx_workflow_test.go index 0768e0ca..e80d08ff 100644 --- a/pkg/registry/buildctx_workflow_test.go +++ b/pkg/registry/buildctx_workflow_test.go @@ -786,6 +786,7 @@ func TestBuildDetailCtx(t *testing.T) { t.Fatalf("setup buildCtx: %v", err) } parent.Level = "org" + parent.UIHistory = []cmdctx.UILink{{Verb: VerbGet, Noun: "detailnoun", Id: "grandparent-id"}} detailCS := &spec.CommandSpec{ Verb: VerbGet, VerbHandler: VerbGet, Noun: "detailnoun", @@ -807,6 +808,9 @@ func TestBuildDetailCtx(t *testing.T) { if detail.Context == nil { t.Fatal("detail.Context is nil") } + if len(detail.UIHistory) != 1 || detail.UIHistory[0].Id != "grandparent-id" { + t.Fatalf("detail.UIHistory = %+v, want parent's UIHistory carried forward", detail.UIHistory) + } } // ---- validateIdParts via AllowsParentId ---- From 5a099b36012d23312883fd6b7c5e9c95b2f1233b Mon Sep 17 00:00:00 2001 From: Naman-Nirwan Date: Fri, 4 Sep 2026 05:16:43 +0530 Subject: [PATCH 08/13] using 'b' for the back op, reserved that key --- pkg/registry/checks.go | 2 +- pkg/registry/uipicker.go | 1 + pkg/registry/uitableview.go | 43 +++++++++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/pkg/registry/checks.go b/pkg/registry/checks.go index 4a580ed0..89907369 100644 --- a/pkg/registry/checks.go +++ b/pkg/registry/checks.go @@ -35,7 +35,7 @@ func (r *Registry) CheckFunctions() error { var reservedUIKeys = map[string]bool{ "p": true, "q": true, "ctrl+c": true, "esc": true, "backspace": true, "up": true, "down": true, "k": true, "j": true, "pgup": true, "pgdown": true, - "home": true, "end": true, + "home": true, "end": true, "b": true, } // checkUICommands validates a noun's ui_commands list: unique non-reserved keys, diff --git a/pkg/registry/uipicker.go b/pkg/registry/uipicker.go index 52286ea4..0603acf8 100644 --- a/pkg/registry/uipicker.go +++ b/pkg/registry/uipicker.go @@ -161,5 +161,6 @@ func buildPickerCtx(getCtx *cmdctx.Ctx, listCs *spec.CommandSpec) *cmdctx.Ctx { Resolver: getCtx.Resolver, FormatFlags: cmdctx.FormatFlags{}, FlagValues: fv, + UIHistory: getCtx.UIHistory, } } diff --git a/pkg/registry/uitableview.go b/pkg/registry/uitableview.go index 2b21d2ea..bce4b54b 100644 --- a/pkg/registry/uitableview.go +++ b/pkg/registry/uitableview.go @@ -100,6 +100,7 @@ type uiTableModel struct { launchUIId string launchUIHandlerFn string // view entry's ui_handler_fn, set on quit linkTarget *cmdctx.UILink // link entry to follow, set on quit + wantBack bool // "b" was pressed with a non-empty UIHistory, set on quit // picker mode — set via newUIPickerModel; enter selects and quits pickerMode bool @@ -825,6 +826,11 @@ func (m uiTableModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit } m.detailMode = false + case "b": + if len(m.ctx.UIHistory) > 0 { + m.wantBack = true + return m, tea.Quit + } case "up", "k": if m.detail.scroll > 0 { m.detail.scroll-- @@ -905,6 +911,12 @@ func (m uiTableModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "q", "ctrl+c": return m, tea.Quit + case "b": + if len(m.ctx.UIHistory) > 0 { + m.wantBack = true + return m, tea.Quit + } + case "enter": if m.pickerMode && !m.loading && len(m.rawItems) > 0 { cursor := m.t.Cursor() @@ -1222,6 +1234,30 @@ func RunUITableForGet(ctx *cmdctx.Ctx, ep *spec.EndpointSpec, getCs *spec.Comman return finishUIExit(ctx, fm) } +// currentScreenLink describes the screen fm is about to leave — for pushing onto +// ctx.UIHistory right before a Hop fires, so a later "b" can redraw it. id is +// ctx.Id for a detail-only overlay (Case 4) or ctx.ParentId for a browse table. +func currentScreenLink(ctx *cmdctx.Ctx, fm uiTableModel) cmdctx.UILink { + profile, org, project := scopeFromCtx(ctx) + screen := cmdctx.ScreenTable + id := ctx.ParentId + if fm.detailOnly { + screen = cmdctx.ScreenDetailForGet + id = ctx.Id + } + return cmdctx.UILink{ + Verb: ctx.Verb, + Noun: ctx.Noun, + Id: id, + Level: ctx.Level, + Profile: profile, + Org: org, + Project: project, + FlagValues: ctx.FlagValues, + Screen: screen, + } +} + // finishUIExit handles common post-Run() actions for the detail overlay: printing // content queued via "p", following a link entry to a different noun's screen, or // handing off to a view entry's ui_handler_fn. @@ -1230,9 +1266,11 @@ func finishUIExit(ctx *cmdctx.Ctx, fm uiTableModel) error { fmt.Println(strings.Join(fm.printOnExit, "\n")) } if fm.linkTarget != nil { + ctx.PushUILink(currentScreenLink(ctx, fm)) return dispatchLink(ctx, fm.linkTarget) } if fm.launchUIId != "" && fm.launchUIHandlerFn != "" { + ctx.PushUILink(currentScreenLink(ctx, fm)) ctx.Id = fm.launchUIId // The handler (e.g. getPipelineLogHandler) branches on --ui itself; this ctx may be // a picker-scoped ctx built for the "list" side that never had --ui set on it. @@ -1242,6 +1280,11 @@ func finishUIExit(ctx *cmdctx.Ctx, fm uiTableModel) error { ctx.FlagValues["ui"] = true return ctx.Resolver.RunUIHandler(ctx, fm.launchUIHandlerFn) } + if fm.wantBack { + if link, ok := ctx.PopUILink(); ok { + return dispatchLink(ctx, &link) + } + } return nil } From 039b00c6805b9e3af27ba169378b9e59d0ad0c7f Mon Sep 17 00:00:00 2001 From: Naman-Nirwan Date: Fri, 4 Sep 2026 05:17:26 +0530 Subject: [PATCH 09/13] test cases for the ui workflow and ui views --- pkg/registry/checks_workflow_test.go | 25 +++++ pkg/registry/uitableview_test.go | 133 +++++++++++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 pkg/registry/uitableview_test.go diff --git a/pkg/registry/checks_workflow_test.go b/pkg/registry/checks_workflow_test.go index 711ce1e5..3c659e51 100644 --- a/pkg/registry/checks_workflow_test.go +++ b/pkg/registry/checks_workflow_test.go @@ -39,6 +39,31 @@ func TestCheckFunctions_TextFormatterMissing(t *testing.T) { } } +func TestCheckUICommands_BReserved(t *testing.T) { + r := New() + r.specs[VerbGet] = append(r.specs[VerbGet], &spec.CommandSpec{ + Command: "get thing", + Verb: VerbGet, + Noun: "thing", + Module: "test", + }) + if err := r.RegisterNoun(spec.NounDef{ + Noun: "thing", + UICommands: []spec.UICommand{ + {Key: "b", UICommandType: spec.UICommandText, Default: true, Noun: "thing"}, + }, + }); err != nil { + t.Fatalf("RegisterNoun: %v", err) + } + err := r.CheckFunctions() + if err == nil { + t.Fatal(`expected error for reserved key "b"`) + } + if !strings.Contains(err.Error(), `key "b" is reserved`) { + t.Fatalf("error = %q, want reserved key mention", err) + } +} + func TestCheckFunctions_BodyFnMissing(t *testing.T) { r := New() r.RegisterNoun(spec.NounDef{Noun: "thing2"}) diff --git a/pkg/registry/uitableview_test.go b/pkg/registry/uitableview_test.go new file mode 100644 index 00000000..6e7cf833 --- /dev/null +++ b/pkg/registry/uitableview_test.go @@ -0,0 +1,133 @@ +// Copyright © 2026 Harness Inc. +// SPDX-License-Identifier: Apache-2.0 + +package registry + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/harness/cli/pkg/cmdctx" +) + +func TestUpdate_BKey_WithHistory_SetsWantBack(t *testing.T) { + m := uiTableModel{ + ctx: &cmdctx.Ctx{UIHistory: []cmdctx.UILink{{Verb: VerbList, Noun: "thing"}}}, + } + newModel, cmd := m.Update(tea.KeyPressMsg{Text: "b"}) + nm := newModel.(uiTableModel) + if !nm.wantBack { + t.Fatal("wantBack = false, want true") + } + if cmd == nil { + t.Fatal("cmd = nil, want tea.Quit") + } +} + +func TestUpdate_BKey_EmptyHistory_NoOp(t *testing.T) { + m := uiTableModel{ctx: &cmdctx.Ctx{}} + newModel, _ := m.Update(tea.KeyPressMsg{Text: "b"}) + nm := newModel.(uiTableModel) + if nm.wantBack { + t.Fatal("wantBack = true, want false (empty UIHistory)") + } +} + +func TestUpdate_BKey_DetailMode_WithHistory_SetsWantBack(t *testing.T) { + m := uiTableModel{ + ctx: &cmdctx.Ctx{UIHistory: []cmdctx.UILink{{Verb: VerbGet, Noun: "thing"}}}, + detailMode: true, + } + newModel, cmd := m.Update(tea.KeyPressMsg{Text: "b"}) + nm := newModel.(uiTableModel) + if !nm.wantBack { + t.Fatal("wantBack = false, want true") + } + if cmd == nil { + t.Fatal("cmd = nil, want tea.Quit") + } +} + +func TestFinishUIExit_PushesLinkOnLinkHop(t *testing.T) { + ctx := &cmdctx.Ctx{ + Verb: VerbList, + Noun: "thing", + ParentId: "parent-1", + Level: "org", + Resolver: New(), + } + fm := uiTableModel{ + linkTarget: &cmdctx.UILink{Verb: VerbGet, Noun: "other"}, + } + // The link target doesn't resolve against an empty Registry; the resulting + // error is expected and irrelevant here — only the push is under test. + _ = finishUIExit(ctx, fm) + + if len(ctx.UIHistory) != 1 { + t.Fatalf("UIHistory len = %d, want 1", len(ctx.UIHistory)) + } + got := ctx.UIHistory[0] + if got.Verb != VerbList || got.Noun != "thing" || got.Id != "parent-1" || got.Level != "org" || got.Screen != cmdctx.ScreenTable { + t.Fatalf("pushed link = %+v, want Verb=%s Noun=thing Id=parent-1 Level=org Screen=ScreenTable", got, VerbList) + } +} + +func TestFinishUIExit_PushesLinkOnViewHop(t *testing.T) { + ctx := &cmdctx.Ctx{ + Verb: VerbGet, + Noun: "thing", + Id: "child-1", + Resolver: New(), + } + fm := uiTableModel{ + detailOnly: true, + launchUIId: "child-1", + launchUIHandlerFn: "missing_handler", + } + // missing_handler isn't registered on an empty Registry; the resulting error + // is expected and irrelevant here — only the push is under test. + _ = finishUIExit(ctx, fm) + + if len(ctx.UIHistory) != 1 { + t.Fatalf("UIHistory len = %d, want 1", len(ctx.UIHistory)) + } + got := ctx.UIHistory[0] + if got.Verb != VerbGet || got.Noun != "thing" || got.Id != "child-1" || got.Screen != cmdctx.ScreenDetailForGet { + t.Fatalf("pushed link = %+v, want Verb=%s Noun=thing Id=child-1 Screen=ScreenDetailForGet", got, VerbGet) + } +} + +func TestFinishUIExit_WantBack_PopsAndReplays(t *testing.T) { + ctx := &cmdctx.Ctx{ + Resolver: New(), + UIHistory: []cmdctx.UILink{{Verb: VerbGet, Noun: "thing", Id: "prev-id"}}, + } + fm := uiTableModel{wantBack: true} + // "get thing" doesn't resolve against an empty Registry; the resulting error + // is expected and irrelevant here — only the pop is under test. + _ = finishUIExit(ctx, fm) + + if len(ctx.UIHistory) != 0 { + t.Fatalf("UIHistory len = %d, want 0 (popped by wantBack)", len(ctx.UIHistory)) + } +} + +func TestFinishUIExit_WantBack_EmptyHistoryNoOp(t *testing.T) { + ctx := &cmdctx.Ctx{Resolver: New()} + fm := uiTableModel{wantBack: true} + if err := finishUIExit(ctx, fm); err != nil { + t.Fatalf("finishUIExit: %v", err) + } +} + +func TestFinishUIExit_NoHopNoPush(t *testing.T) { + ctx := &cmdctx.Ctx{Verb: VerbList, Noun: "thing", Resolver: New()} + fm := uiTableModel{} + if err := finishUIExit(ctx, fm); err != nil { + t.Fatalf("finishUIExit: %v", err) + } + if len(ctx.UIHistory) != 0 { + t.Fatalf("UIHistory len = %d, want 0 (no hop fired)", len(ctx.UIHistory)) + } +} From f8de5ca367f1a015a6c147c49919e62b4adc2d3d Mon Sep 17 00:00:00 2001 From: Naman-Nirwan Date: Fri, 4 Sep 2026 13:51:49 +0530 Subject: [PATCH 10/13] back to the exact cursor position for the listPos --- pkg/cmdctx/cmdctx.go | 3 ++ pkg/registry/buildctx.go | 1 + pkg/registry/uitableview.go | 63 ++++++++++++++++++++++++++----------- pkg/tui/table.go | 7 +++++ 4 files changed, 55 insertions(+), 19 deletions(-) diff --git a/pkg/cmdctx/cmdctx.go b/pkg/cmdctx/cmdctx.go index 963547c3..fa19d92b 100644 --- a/pkg/cmdctx/cmdctx.go +++ b/pkg/cmdctx/cmdctx.go @@ -234,6 +234,9 @@ type Ctx struct { // UIHistory is the --ui back-navigation stack: one UILink pushed per Hop // (link/up/view), popped by the "b" key. Session-lifetime only. UIHistory []UILink + // RestoreListPos is the cursor row (within the first-loaded page) to seed a + // freshly built table with when replaying a popped UILink's ListPos. + RestoreListPos int } // ScopedAuth returns Auth adjusted for Level: "org" clears ProjectID, "account" diff --git a/pkg/registry/buildctx.go b/pkg/registry/buildctx.go index 63bb4904..3773c503 100644 --- a/pkg/registry/buildctx.go +++ b/pkg/registry/buildctx.go @@ -370,6 +370,7 @@ func buildLinkCtx(ctx *cmdctx.Ctx, link *cmdctx.UILink, targetCs *spec.CommandSp } } else { newCtx.ParentId = link.Id + newCtx.RestoreListPos = link.ListPos } return newCtx, nil } diff --git a/pkg/registry/uitableview.go b/pkg/registry/uitableview.go index bce4b54b..adecac48 100644 --- a/pkg/registry/uitableview.go +++ b/pkg/registry/uitableview.go @@ -133,6 +133,11 @@ type uiTableModel struct { width int height int + + // listpos restore — seeded from ctx.RestoreListPos, applied once on the + // first page load only (later page loads always GotoTop as before). + restoreCursor int + restoreApplied bool } // tableHeight returns the number of data rows visible (excludes header row). @@ -164,21 +169,22 @@ func newUITableModel( } return uiTableModel{ - ctx: ctx, - ep: ep, - tspec: tspec, - fields: fields, - exprEnv: exprEnv, - t: t, - colDefs: colDefs, - titleLine: titleLine, - pageSize: pageSize, - loading: true, - width: termWidth, - height: termHeight, - hasSearch: hasSearch, - getCs: getCs, - uiCommands: uiCommands, + ctx: ctx, + ep: ep, + tspec: tspec, + fields: fields, + exprEnv: exprEnv, + t: t, + colDefs: colDefs, + titleLine: titleLine, + pageSize: pageSize, + loading: true, + width: termWidth, + height: termHeight, + hasSearch: hasSearch, + getCs: getCs, + uiCommands: uiCommands, + restoreCursor: ctx.RestoreListPos, } } @@ -433,7 +439,14 @@ func (m *uiTableModel) applyPage(rawRows []tui.Row, rawItems []any) { m.colDefs = cols m.t.SetColumns(cols) m.t.SetRows(rawRows) - m.t.GotoTop() + if !m.restoreApplied { + //write in a file + os.WriteFile("cursor.txt", []byte(fmt.Sprintf("%v", m.restoreCursor)), 0644) + m.t.SetCursor(m.restoreCursor) + m.restoreApplied = true + } else { + m.t.GotoTop() + } } // enterColPick initialises the column picker from the current tspec. @@ -827,7 +840,11 @@ func (m uiTableModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.detailMode = false case "b": - if len(m.ctx.UIHistory) > 0 { + if !m.detailOnly { + // In-place detail flip (reached via "enter", not a Hop) — collapse + // back to the list underneath, same as esc, before touching History. + m.detailMode = false + } else if len(m.ctx.UIHistory) > 0 { m.wantBack = true return m, tea.Quit } @@ -1245,7 +1262,7 @@ func currentScreenLink(ctx *cmdctx.Ctx, fm uiTableModel) cmdctx.UILink { screen = cmdctx.ScreenDetailForGet id = ctx.Id } - return cmdctx.UILink{ + link := cmdctx.UILink{ Verb: ctx.Verb, Noun: ctx.Noun, Id: id, @@ -1255,7 +1272,9 @@ func currentScreenLink(ctx *cmdctx.Ctx, fm uiTableModel) cmdctx.UILink { Project: project, FlagValues: ctx.FlagValues, Screen: screen, + ListPos: fm.t.Cursor(), } + return link } // finishUIExit handles common post-Run() actions for the detail overlay: printing @@ -1278,7 +1297,13 @@ func finishUIExit(ctx *cmdctx.Ctx, fm uiTableModel) error { ctx.FlagValues = map[string]any{} } ctx.FlagValues["ui"] = true - return ctx.Resolver.RunUIHandler(ctx, fm.launchUIHandlerFn) + if err := ctx.Resolver.RunUIHandler(ctx, fm.launchUIHandlerFn); err != nil { + return err + } + if link, ok := ctx.PopUILink(); ok { + return dispatchLink(ctx, &link) + } + return nil } if fm.wantBack { if link, ok := ctx.PopUILink(); ok { diff --git a/pkg/tui/table.go b/pkg/tui/table.go index 002bbf29..39bb442b 100644 --- a/pkg/tui/table.go +++ b/pkg/tui/table.go @@ -82,6 +82,13 @@ func (t *TableModel) GotoTop() { t.scroll = 0 } +// SetCursor moves the cursor to row, clamped to [0, len(rows)-1] (or 0 when +// there are no rows), and scrolls it into view. +func (t *TableModel) SetCursor(row int) { + t.cursor = min(max(row, 0), max(len(t.rows)-1, 0)) + t.clampScroll() +} + func (t *TableModel) Cursor() int { return t.cursor } From 6f007449d57d2c0383c4a508952e960c52aa3877 Mon Sep 17 00:00:00 2001 From: Naman-Nirwan Date: Fri, 4 Sep 2026 13:53:34 +0530 Subject: [PATCH 11/13] Unit test cases for the cursor position in the list --- pkg/registry/uitableview_test.go | 142 ++++++++++++++++++++++++++++++- pkg/tui/table_test.go | 61 +++++++++++++ 2 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 pkg/tui/table_test.go diff --git a/pkg/registry/uitableview_test.go b/pkg/registry/uitableview_test.go index 6e7cf833..150faf37 100644 --- a/pkg/registry/uitableview_test.go +++ b/pkg/registry/uitableview_test.go @@ -4,11 +4,14 @@ package registry import ( + "context" "testing" tea "charm.land/bubbletea/v2" "github.com/harness/cli/pkg/cmdctx" + "github.com/harness/cli/pkg/spec" + "github.com/harness/cli/pkg/tui" ) func TestUpdate_BKey_WithHistory_SetsWantBack(t *testing.T) { @@ -34,10 +37,11 @@ func TestUpdate_BKey_EmptyHistory_NoOp(t *testing.T) { } } -func TestUpdate_BKey_DetailMode_WithHistory_SetsWantBack(t *testing.T) { +func TestUpdate_BKey_DetailOnlyMode_WithHistory_SetsWantBack(t *testing.T) { m := uiTableModel{ ctx: &cmdctx.Ctx{UIHistory: []cmdctx.UILink{{Verb: VerbGet, Noun: "thing"}}}, detailMode: true, + detailOnly: true, } newModel, cmd := m.Update(tea.KeyPressMsg{Text: "b"}) nm := newModel.(uiTableModel) @@ -49,6 +53,25 @@ func TestUpdate_BKey_DetailMode_WithHistory_SetsWantBack(t *testing.T) { } } +func TestUpdate_BKey_InPlaceDetailFlip_CollapsesLikeEsc(t *testing.T) { + m := uiTableModel{ + ctx: &cmdctx.Ctx{UIHistory: []cmdctx.UILink{{Verb: VerbGet, Noun: "thing"}}}, + detailMode: true, + detailOnly: false, + } + newModel, cmd := m.Update(tea.KeyPressMsg{Text: "b"}) + nm := newModel.(uiTableModel) + if nm.detailMode { + t.Fatal("detailMode = true, want false (b should collapse to the list, like esc)") + } + if nm.wantBack { + t.Fatal("wantBack = true, want false (should not touch History when collapsing an in-place flip)") + } + if cmd != nil { + t.Fatal("cmd != nil, want nil (should not quit)") + } +} + func TestFinishUIExit_PushesLinkOnLinkHop(t *testing.T) { ctx := &cmdctx.Ctx{ Verb: VerbList, @@ -98,6 +121,40 @@ func TestFinishUIExit_PushesLinkOnViewHop(t *testing.T) { } } +func TestCurrentScreenLink_CapturesListPosOnTable(t *testing.T) { + ctx := &cmdctx.Ctx{Verb: VerbList, Noun: "thing", ParentId: "parent-1"} + table := tui.NewTable(nil, 5, 40) + rows := make([]tui.Row, 10) + for i := range rows { + rows[i] = tui.Row{"x"} + } + table.SetRows(rows) + table.SetCursor(4) + fm := uiTableModel{t: table} + + link := currentScreenLink(ctx, fm) + if link.ListPos != 4 { + t.Fatalf("ListPos = %d, want 4", link.ListPos) + } +} + +func TestCurrentScreenLink_DetailModeDoesNotCaptureListPos(t *testing.T) { + ctx := &cmdctx.Ctx{Verb: VerbGet, Noun: "thing", Id: "child-1"} + table := tui.NewTable(nil, 5, 40) + rows := make([]tui.Row, 10) + for i := range rows { + rows[i] = tui.Row{"x"} + } + table.SetRows(rows) + table.SetCursor(4) + fm := uiTableModel{t: table, detailMode: true, detailOnly: true} + + link := currentScreenLink(ctx, fm) + if link.ListPos != 0 { + t.Fatalf("ListPos = %d, want 0 (detail screens have no list cursor to capture)", link.ListPos) + } +} + func TestFinishUIExit_WantBack_PopsAndReplays(t *testing.T) { ctx := &cmdctx.Ctx{ Resolver: New(), @@ -121,6 +178,32 @@ func TestFinishUIExit_WantBack_EmptyHistoryNoOp(t *testing.T) { } } +func TestFinishUIExit_ViewHop_ResumesLeftScreen(t *testing.T) { + r := New() + r.RegisterWorkflow("noop_handler", func(*cmdctx.Ctx) error { return nil }) + ctx := &cmdctx.Ctx{ + Verb: VerbGet, + Noun: "thing", + Id: "child-1", + Resolver: r, + UIHistory: []cmdctx.UILink{{Verb: VerbGet, Noun: "thing", Id: "prev-id"}}, + } + fm := uiTableModel{ + detailOnly: true, + launchUIId: "child-1", + launchUIHandlerFn: "noop_handler", + } + // The handler pushes the screen it's leaving, runs "noop_handler" (returns nil), then + // pops that same entry back off to resume it via dispatchLink — which doesn't resolve + // against an empty Registry; the resulting error is expected and irrelevant here. Only + // the net stack effect (resume, not leak or exit) is under test. + _ = finishUIExit(ctx, fm) + + if len(ctx.UIHistory) != 1 || ctx.UIHistory[0].Id != "prev-id" { + t.Fatalf("UIHistory = %+v, want just the pre-existing prev-id entry (view-hop's own push+pop should net to zero)", ctx.UIHistory) + } +} + func TestFinishUIExit_NoHopNoPush(t *testing.T) { ctx := &cmdctx.Ctx{Verb: VerbList, Noun: "thing", Resolver: New()} fm := uiTableModel{} @@ -131,3 +214,60 @@ func TestFinishUIExit_NoHopNoPush(t *testing.T) { t.Fatalf("UIHistory len = %d, want 0 (no hop fired)", len(ctx.UIHistory)) } } + +func TestBuildLinkCtx_TableScreen_CarriesListPosToRestoreListPos(t *testing.T) { + ctx := &cmdctx.Ctx{Context: context.Background(), Resolver: New()} + link := &cmdctx.UILink{Verb: VerbList, Noun: "thing", Id: "parent-1", Screen: cmdctx.ScreenTable, ListPos: 4} + targetCs := &spec.CommandSpec{Verb: VerbList, Noun: "thing", NoAuth: true} + + newCtx, err := buildLinkCtx(ctx, link, targetCs) + if err != nil { + t.Fatalf("buildLinkCtx: %v", err) + } + if newCtx.RestoreListPos != 4 { + t.Fatalf("RestoreListPos = %d, want 4", newCtx.RestoreListPos) + } + if newCtx.ParentId != "parent-1" { + t.Fatalf("ParentId = %q, want parent-1", newCtx.ParentId) + } +} + +func TestBuildLinkCtx_DetailScreen_DoesNotSetRestoreListPos(t *testing.T) { + ctx := &cmdctx.Ctx{Context: context.Background(), Resolver: New()} + link := &cmdctx.UILink{Verb: VerbGet, Noun: "thing", Id: "child-1", Screen: cmdctx.ScreenDetailForGet, ListPos: 4} + targetCs := &spec.CommandSpec{Verb: VerbGet, Noun: "thing", NoAuth: true} + + newCtx, err := buildLinkCtx(ctx, link, targetCs) + if err != nil { + t.Fatalf("buildLinkCtx: %v", err) + } + if newCtx.RestoreListPos != 0 { + t.Fatalf("RestoreListPos = %d, want 0 (detail screens have no list cursor to restore)", newCtx.RestoreListPos) + } +} + +func TestApplyPage_FirstLoadRestoresCursor_SubsequentLoadsGotoTop(t *testing.T) { + rows := make([]tui.Row, 10) + for i := range rows { + rows[i] = tui.Row{"x"} + } + m := &uiTableModel{ + tspec: &spec.TableSpec{Columns: []spec.TableColumn{{Header: "ID", Expr: "it.id"}}}, + t: tui.NewTable(nil, 5, 40), + width: 40, + restoreCursor: 4, + } + + m.applyPage(rows, nil) + if got := m.t.Cursor(); got != 4 { + t.Fatalf("Cursor() after first load = %d, want 4 (restored)", got) + } + if !m.restoreApplied { + t.Fatal("restoreApplied = false after first load, want true") + } + + m.applyPage(rows, nil) + if got := m.t.Cursor(); got != 0 { + t.Fatalf("Cursor() after second load = %d, want 0 (GotoTop, not re-restored)", got) + } +} diff --git a/pkg/tui/table_test.go b/pkg/tui/table_test.go new file mode 100644 index 00000000..b1db50ba --- /dev/null +++ b/pkg/tui/table_test.go @@ -0,0 +1,61 @@ +// Copyright © 2026 Harness Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import "testing" + +func rowsN(n int) []Row { + rows := make([]Row, n) + for i := range rows { + rows[i] = Row{"x"} + } + return rows +} + +func TestSetCursor_WithinRange(t *testing.T) { + tm := NewTable(nil, 5, 40) + tm.SetRows(rowsN(10)) + tm.SetCursor(3) + if got := tm.Cursor(); got != 3 { + t.Fatalf("Cursor() = %d, want 3", got) + } +} + +func TestSetCursor_NegativeClampsToZero(t *testing.T) { + tm := NewTable(nil, 5, 40) + tm.SetRows(rowsN(10)) + tm.SetCursor(-5) + if got := tm.Cursor(); got != 0 { + t.Fatalf("Cursor() = %d, want 0", got) + } +} + +func TestSetCursor_OutOfRangeClampsToLast(t *testing.T) { + tm := NewTable(nil, 5, 40) + tm.SetRows(rowsN(10)) + tm.SetCursor(100) + if got := tm.Cursor(); got != 9 { + t.Fatalf("Cursor() = %d, want 9 (last row)", got) + } +} + +func TestSetCursor_EmptyRows(t *testing.T) { + tm := NewTable(nil, 5, 40) + tm.SetCursor(3) + if got := tm.Cursor(); got != 0 { + t.Fatalf("Cursor() = %d, want 0 (no rows)", got) + } +} + +func TestSetCursor_ScrollFollowsCursor(t *testing.T) { + tm := NewTable(nil, 5, 40) // height=5 visible rows + tm.SetRows(rowsN(20)) + tm.SetCursor(15) + if tm.scroll == 0 { + t.Fatal("scroll = 0, want scrolled down to keep cursor in view") + } + if tm.cursor < tm.scroll || tm.cursor >= tm.scroll+tm.height { + t.Fatalf("cursor %d not within visible window [%d, %d)", tm.cursor, tm.scroll, tm.scroll+tm.height) + } +} From aee3578b87e44b7737a433187d9bbfda237ef0b2 Mon Sep 17 00:00:00 2001 From: Naman-Nirwan Date: Fri, 4 Sep 2026 16:07:27 +0530 Subject: [PATCH 12/13] set the search flag info from UiTableModel in tea.program() --- pkg/registry/uitableview.go | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/pkg/registry/uitableview.go b/pkg/registry/uitableview.go index adecac48..c1dab526 100644 --- a/pkg/registry/uitableview.go +++ b/pkg/registry/uitableview.go @@ -440,8 +440,6 @@ func (m *uiTableModel) applyPage(rawRows []tui.Row, rawItems []any) { m.t.SetColumns(cols) m.t.SetRows(rawRows) if !m.restoreApplied { - //write in a file - os.WriteFile("cursor.txt", []byte(fmt.Sprintf("%v", m.restoreCursor)), 0644) m.t.SetCursor(m.restoreCursor) m.restoreApplied = true } else { @@ -1262,6 +1260,15 @@ func currentScreenLink(ctx *cmdctx.Ctx, fm uiTableModel) cmdctx.UILink { screen = cmdctx.ScreenDetailForGet id = ctx.Id } + fv := ctx.FlagValues + if fm.hasSearch { + copied := make(map[string]any, len(ctx.FlagValues)) + for k, v := range ctx.FlagValues { + copied[k] = v + } + copied["search"] = fm.searchTerm + fv = copied + } link := cmdctx.UILink{ Verb: ctx.Verb, Noun: ctx.Noun, @@ -1270,9 +1277,12 @@ func currentScreenLink(ctx *cmdctx.Ctx, fm uiTableModel) cmdctx.UILink { Profile: profile, Org: org, Project: project, - FlagValues: ctx.FlagValues, + FlagValues: fv, Screen: screen, - ListPos: fm.t.Cursor(), + // ListPos is always captured from the underlying table, even mid detail-flip: + // "b" always resumes the list, never the detail overlay, and detailOnly + // screens (Case 4) never populate fm.t, so its Cursor() is a natural 0 there. + ListPos: fm.t.Cursor(), } return link } From 7f98e91baf7999cccd8d8fd6a27715cb1cc1769f Mon Sep 17 00:00:00 2001 From: Naman-Nirwan Date: Fri, 4 Sep 2026 16:08:29 +0530 Subject: [PATCH 13/13] unit tests for the search flag --- pkg/registry/uitableview_test.go | 50 +++++++++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/pkg/registry/uitableview_test.go b/pkg/registry/uitableview_test.go index 150faf37..1d343d77 100644 --- a/pkg/registry/uitableview_test.go +++ b/pkg/registry/uitableview_test.go @@ -138,8 +138,11 @@ func TestCurrentScreenLink_CapturesListPosOnTable(t *testing.T) { } } -func TestCurrentScreenLink_DetailModeDoesNotCaptureListPos(t *testing.T) { - ctx := &cmdctx.Ctx{Verb: VerbGet, Noun: "thing", Id: "child-1"} +func TestCurrentScreenLink_CapturesListPosEvenMidDetailFlip(t *testing.T) { + // "b" always resumes the underlying list, never the detail overlay, so an + // in-place detail flip (detailMode true, detailOnly false) over a table must + // still capture that table's live cursor. + ctx := &cmdctx.Ctx{Verb: VerbList, Noun: "thing", ParentId: "parent-1"} table := tui.NewTable(nil, 5, 40) rows := make([]tui.Row, 10) for i := range rows { @@ -147,11 +150,50 @@ func TestCurrentScreenLink_DetailModeDoesNotCaptureListPos(t *testing.T) { } table.SetRows(rows) table.SetCursor(4) - fm := uiTableModel{t: table, detailMode: true, detailOnly: true} + fm := uiTableModel{t: table, detailMode: true, detailOnly: false} + + link := currentScreenLink(ctx, fm) + if link.ListPos != 4 { + t.Fatalf("ListPos = %d, want 4 (mid-flip should still capture the list cursor)", link.ListPos) + } +} + +func TestCurrentScreenLink_DetailOnlyScreenHasZeroListPos(t *testing.T) { + // Case 4 detail-only Hops never populate fm.t, so its Cursor() is naturally 0. + ctx := &cmdctx.Ctx{Verb: VerbGet, Noun: "thing", Id: "child-1"} + fm := uiTableModel{detailMode: true, detailOnly: true} link := currentScreenLink(ctx, fm) if link.ListPos != 0 { - t.Fatalf("ListPos = %d, want 0 (detail screens have no list cursor to capture)", link.ListPos) + t.Fatalf("ListPos = %d, want 0 (detail-only screens have no underlying table)", link.ListPos) + } +} + +func TestCurrentScreenLink_CapturesSearchTermIntoFlagValues(t *testing.T) { + ctx := &cmdctx.Ctx{Verb: VerbList, Noun: "thing", ParentId: "parent-1", FlagValues: map[string]any{"other": "x"}} + table := tui.NewTable(nil, 5, 40) + fm := uiTableModel{t: table, hasSearch: true, searchTerm: "foo"} + + link := currentScreenLink(ctx, fm) + if got := link.FlagValues["search"]; got != "foo" { + t.Fatalf("FlagValues[search] = %v, want %q", got, "foo") + } + if got := link.FlagValues["other"]; got != "x" { + t.Fatalf("FlagValues[other] = %v, want %q (should carry other flags through)", got, "x") + } + if ctx.FlagValues["search"] != nil { + t.Fatalf("ctx.FlagValues mutated, want the copy left untouched") + } +} + +func TestCurrentScreenLink_NoSearchLeavesFlagValuesUnchanged(t *testing.T) { + ctx := &cmdctx.Ctx{Verb: VerbList, Noun: "thing", ParentId: "parent-1", FlagValues: map[string]any{"other": "x"}} + table := tui.NewTable(nil, 5, 40) + fm := uiTableModel{t: table, hasSearch: false} + + link := currentScreenLink(ctx, fm) + if _, ok := link.FlagValues["search"]; ok { + t.Fatal("FlagValues[search] present, want no injected key when hasSearch is false") } }