From bf658a878e8f4da9e99869052b952b1508fab582 Mon Sep 17 00:00:00 2001 From: Igor Churmeev Date: Sat, 17 Oct 2026 12:41:19 +0000 Subject: [PATCH 1/5] refactor(nomad): write the rule of every namespace once --- internal/nomad/client.go | 16 ++++++++++++++++ internal/nomad/client_test.go | 12 ++++++++++++ internal/nomad/edit.go | 6 ++---- internal/ui/jobs.go | 2 +- internal/ui/namespace.go | 2 +- internal/ui/variable.go | 5 +---- 6 files changed, 33 insertions(+), 10 deletions(-) diff --git a/internal/nomad/client.go b/internal/nomad/client.go index aa82b12..71d67da 100644 --- a/internal/nomad/client.go +++ b/internal/nomad/client.go @@ -15,6 +15,22 @@ const AllNamespaces = "*" // DefaultNamespace is the namespace every cluster has, and keeps. const DefaultNamespace = "default" +// EveryNamespace says the namespace stands for all of them: none named, or +// AllNamespaces. +func EveryNamespace(namespace string) bool { + return namespace == "" || namespace == AllNamespaces +} + +// TargetNamespace is where something new goes from a session in namespace: +// that namespace, or DefaultNamespace when the session looks at all of them. +func TargetNamespace(namespace string) string { + if EveryNamespace(namespace) { + return DefaultNamespace + } + + return namespace +} + // Config says which cluster to talk to. An empty address or region falls // back to the environment, the same variables the nomad command reads, // which is also where the token comes from. diff --git a/internal/nomad/client_test.go b/internal/nomad/client_test.go index efd9f27..17b1fc3 100644 --- a/internal/nomad/client_test.go +++ b/internal/nomad/client_test.go @@ -297,3 +297,15 @@ func TestNew_TheCertificatesOfANamedCluster(t *testing.T) { }) require.ErrorContains(t, err, "missing-ca.pem") } + +func TestEveryNamespace(t *testing.T) { + for namespace, want := range map[string]bool{"": true, "*": true, "default": false, "web": false} { + require.Equal(t, want, nomad.EveryNamespace(namespace), "namespace %q", namespace) + } +} + +func TestTargetNamespace(t *testing.T) { + for namespace, want := range map[string]string{"": "default", "*": "default", "default": "default", "web": "web"} { + require.Equal(t, want, nomad.TargetNamespace(namespace), "namespace %q", namespace) + } +} diff --git a/internal/nomad/edit.go b/internal/nomad/edit.go index 19fe93d..a6462db 100644 --- a/internal/nomad/edit.go +++ b/internal/nomad/edit.go @@ -47,7 +47,7 @@ func (c *Client) jobOf(ctx context.Context, namespace, source string, vars JobVa return nil, nil, nil, err } - if namespace != "" && namespace != AllNamespaces { + if !EveryNamespace(namespace) { job.Namespace = &namespace } @@ -339,9 +339,7 @@ type JobTarget struct { // file names, or else the one of the session, and default when the session // looks at all of them. func (c *Client) JobTarget(ctx context.Context, namespace, source string) (JobTarget, error) { - if namespace == "" || namespace == AllNamespaces { - namespace = DefaultNamespace - } + namespace = TargetNamespace(namespace) job, _, err := c.parseJob(ctx, namespace, source, JobVariables{}, false) if err != nil { diff --git a/internal/ui/jobs.go b/internal/ui/jobs.go index 3cf84dc..7920e73 100644 --- a/internal/ui/jobs.go +++ b/internal/ui/jobs.go @@ -197,7 +197,7 @@ func (p jobsPage) press(k string, e env) (page, outcome, bool) { // namespaceLabel is the namespace as it reads in a title. func namespaceLabel(namespace string) string { - if namespace == "" || namespace == nomad.AllNamespaces { + if nomad.EveryNamespace(namespace) { return "all" } diff --git a/internal/ui/namespace.go b/internal/ui/namespace.go index 877290f..3678a8b 100644 --- a/internal/ui/namespace.go +++ b/internal/ui/namespace.go @@ -101,7 +101,7 @@ func (s namespaceState) namespaceColumnData() []namespaceKey { keys := []namespaceKey{{ Key: "<0>", Name: "all", - Active: s.namespace == "" || s.namespace == nomad.AllNamespaces, + Active: nomad.EveryNamespace(s.namespace), }} for i, name := range s.namespaceOrder { diff --git a/internal/ui/variable.go b/internal/ui/variable.go index c650935..297724c 100644 --- a/internal/ui/variable.go +++ b/internal/ui/variable.go @@ -273,10 +273,7 @@ func unlocked[P any](acted func(p P, e env) (nomad.Variable, bool)) func(p P, e // editor. It goes to the namespace of the session, to default when the // session looks at every one. func newVariable(p variablesPage, e env) (variablesPage, outcome) { - namespace := e.namespace - if namespace == "" || namespace == nomad.AllNamespaces { - namespace = nomad.DefaultNamespace - } + namespace := nomad.TargetNamespace(e.namespace) return p, then(lineMsg{ prefix: "new variable in " + namespace + " at: ", From 9843771e36145c7a1e4e24f5fbc8984f54c857ad Mon Sep 17 00:00:00 2001 From: Igor Churmeev Date: Sat, 17 Oct 2026 12:43:15 +0000 Subject: [PATCH 2/5] refactor(nomad): map a deployment from the API in one place --- internal/nomad/deployments.go | 9 +--- internal/nomad/plugin.go | 22 ++++----- internal/nomad/resources.go | 76 ++++++++++---------------------- internal/nomad/resources_test.go | 27 +++++++++--- 4 files changed, 54 insertions(+), 80 deletions(-) diff --git a/internal/nomad/deployments.go b/internal/nomad/deployments.go index e0ceb6e..bac0d21 100644 --- a/internal/nomad/deployments.go +++ b/internal/nomad/deployments.go @@ -81,14 +81,7 @@ func (c *Client) JobDeployments(ctx context.Context, namespace, jobID string) ([ } func newDeploymentDetail(d *api.Deployment) DeploymentDetail { - detail := DeploymentDetail{Deployment: Deployment{ - ID: d.ID, - JobID: d.JobID, - Namespace: d.Namespace, - JobVersion: d.JobVersion, - Status: d.Status, - StatusDescription: d.StatusDescription, - }} + detail := DeploymentDetail{Deployment: newDeployment(d)} for name, state := range d.TaskGroups { if state == nil { diff --git a/internal/nomad/plugin.go b/internal/nomad/plugin.go index 1bc7b50..9060ee8 100644 --- a/internal/nomad/plugin.go +++ b/internal/nomad/plugin.go @@ -50,21 +50,17 @@ type PluginInstance struct { // Plugins lists the CSI plugins of the cluster. func (c *Client) Plugins(ctx context.Context) ([]Plugin, error) { list, _, err := c.api.CSIPlugins().List(c.query(ctx, "")) - if err != nil { - return nil, err - } - out := make([]Plugin, 0, len(list)) - for _, p := range list { - out = append(out, Plugin{ - ID: p.ID, - Provider: p.Provider, - Controllers: Health{Healthy: p.ControllersHealthy, Expected: p.ControllersExpected}, - Nodes: Health{Healthy: p.NodesHealthy, Expected: p.NodesExpected}, - }) - } + return listed(list, err, newPlugin) +} - return out, nil +func newPlugin(p *api.CSIPluginListStub) Plugin { + return Plugin{ + ID: p.ID, + Provider: p.Provider, + Controllers: Health{Healthy: p.ControllersHealthy, Expected: p.ControllersExpected}, + Nodes: Health{Healthy: p.NodesHealthy, Expected: p.NodesExpected}, + } } // Plugin reads one CSI plugin. diff --git a/internal/nomad/resources.go b/internal/nomad/resources.go index 22a6cf5..8b4644d 100644 --- a/internal/nomad/resources.go +++ b/internal/nomad/resources.go @@ -20,23 +20,19 @@ type Deployment struct { // Deployments lists the rollouts of a namespace. func (c *Client) Deployments(ctx context.Context, namespace string) ([]Deployment, error) { list, _, err := c.api.Deployments().List(c.query(ctx, namespace)) - if err != nil { - return nil, err - } - out := make([]Deployment, 0, len(list)) - for _, d := range list { - out = append(out, Deployment{ - ID: d.ID, - JobID: d.JobID, - Namespace: d.Namespace, - JobVersion: d.JobVersion, - Status: d.Status, - StatusDescription: d.StatusDescription, - }) - } + return listed(list, err, newDeployment) +} - return out, nil +func newDeployment(d *api.Deployment) Deployment { + return Deployment{ + ID: d.ID, + JobID: d.JobID, + Namespace: d.Namespace, + JobVersion: d.JobVersion, + Status: d.Status, + StatusDescription: d.StatusDescription, + } } // Namespace is one namespace of the cluster. @@ -49,16 +45,12 @@ type Namespace struct { // Namespaces lists the namespaces of the cluster. func (c *Client) Namespaces(ctx context.Context) ([]Namespace, error) { list, _, err := c.api.Namespaces().List(c.query(ctx, "")) - if err != nil { - return nil, err - } - out := make([]Namespace, 0, len(list)) - for _, n := range list { - out = append(out, Namespace{Name: n.Name, Description: n.Description, Quota: n.Quota}) - } + return listed(list, err, newNamespace) +} - return out, nil +func newNamespace(n *api.Namespace) Namespace { + return Namespace{Name: n.Name, Description: n.Description, Quota: n.Quota} } // Service is one name in the service catalog. @@ -104,16 +96,8 @@ type Evaluation struct { // Evaluations lists the scheduling decisions of a namespace. func (c *Client) Evaluations(ctx context.Context, namespace string) ([]Evaluation, error) { list, _, err := c.api.Evaluations().List(c.query(ctx, namespace)) - if err != nil { - return nil, err - } - out := make([]Evaluation, 0, len(list)) - for _, e := range list { - out = append(out, newEvaluation(e)) - } - - return out, nil + return listed(list, err, newEvaluation) } func newEvaluation(e *api.Evaluation) Evaluation { @@ -208,16 +192,8 @@ type VariableDetail struct { // Variables lists what the variable store of a namespace holds. func (c *Client) Variables(ctx context.Context, namespace string) ([]Variable, error) { list, _, err := c.api.Variables().List(c.query(ctx, namespace)) - if err != nil { - return nil, err - } - - out := make([]Variable, 0, len(list)) - for _, v := range list { - out = append(out, variableOf(v)) - } - return out, nil + return listed(list, err, variableOf) } // Variable reads a variable with its values. @@ -264,22 +240,18 @@ type NodePool struct { // NodePools lists the pools nodes are grouped into. func (c *Client) NodePools(ctx context.Context) ([]NodePool, error) { list, _, err := c.api.NodePools().List(c.query(ctx, "")) - if err != nil { - return nil, err - } - out := make([]NodePool, 0, len(list)) - for _, p := range list { - pool := NodePool{Name: p.Name, Description: p.Description} + return listed(list, err, newNodePool) +} - if p.SchedulerConfiguration != nil { - pool.Scheduler = string(p.SchedulerConfiguration.SchedulerAlgorithm) - } +func newNodePool(p *api.NodePool) NodePool { + pool := NodePool{Name: p.Name, Description: p.Description} - out = append(out, pool) + if p.SchedulerConfiguration != nil { + pool.Scheduler = string(p.SchedulerConfiguration.SchedulerAlgorithm) } - return out, nil + return pool } // PoolNodes lists the clients of a node pool, as the cluster counts them: diff --git a/internal/nomad/resources_test.go b/internal/nomad/resources_test.go index e9e502b..377a507 100644 --- a/internal/nomad/resources_test.go +++ b/internal/nomad/resources_test.go @@ -76,10 +76,10 @@ func TestDeployments_Read(t *testing.T) { r.NoError(err) r.Len(deployments, 1) - r.Equal("dep-1", deployments[0].ID) - r.Equal("web", deployments[0].JobID) - r.Equal("running", deployments[0].Status) - r.Equal(uint64(3), deployments[0].JobVersion) + r.Equal(nomad.Deployment{ + ID: "dep-1", JobID: "web", Namespace: "production", JobVersion: 3, + Status: "running", StatusDescription: "Deployment is running", + }, deployments[0]) } func TestNamespaces_Read(t *testing.T) { @@ -121,9 +121,11 @@ func TestEvaluations_Read(t *testing.T) { r.NoError(err) r.Len(evals, 1) - r.Equal("eval-1", evals[0].ID) - r.Equal("job-register", evals[0].TriggeredBy) - r.Equal("complete", evals[0].Status) + r.Equal(nomad.Evaluation{ + ID: "eval-1", JobID: "web", Namespace: "production", Type: "service", + TriggeredBy: "job-register", Status: "complete", + Created: evals[0].Created, + }, evals[0]) r.False(evals[0].Created.IsZero()) } @@ -175,6 +177,7 @@ func TestVariables_Read(t *testing.T) { r.Len(variables, 1) r.Equal("nomad/jobs/web", variables[0].Path) + r.Equal("production", variables[0].Namespace) // The index a delete gives, so that a variable changed since is kept. r.Equal(uint64(769), variables[0].Index) @@ -226,6 +229,16 @@ func TestNodePools_Read(t *testing.T) { r.Equal("binpack", pools[0].Scheduler) } +func TestNodePools_WithoutASchedulerConfiguration(t *testing.T) { + r := require.New(t) + + client, _ := recorder(t, `[{"Name": "gpu", "Description": "gpus"}]`) + + pools, err := client.NodePools(context.Background()) + r.NoError(err) + r.Equal([]nomad.NodePool{{Name: "gpu", Description: "gpus"}}, pools) +} + func TestPoolJobs(t *testing.T) { r := require.New(t) From d59432b1758c1382fa4676efc1b24eac1f71a756 Mon Sep 17 00:00:00 2001 From: Igor Churmeev Date: Sat, 17 Oct 2026 12:44:51 +0000 Subject: [PATCH 3/5] refactor(ui): name the inner width of the box once --- internal/ui/app.go | 10 +++++++--- internal/ui/app_test.go | 6 ++++++ internal/ui/client.go | 2 +- internal/ui/client_test.go | 10 +++++----- internal/ui/screen.go | 2 +- 5 files changed, 20 insertions(+), 10 deletions(-) diff --git a/internal/ui/app.go b/internal/ui/app.go index 92dfaae..1dcb177 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -456,7 +456,7 @@ func (m Model) render() string { parts = append(parts, indent(m.prompt.view(width), screenPadX)) } - title, body := m.body(width - 2) + title, body := m.body(m.innerWidth()) framed := paintedFrame(title, body, width, m.bodyHeight(), m.border()) @@ -588,6 +588,10 @@ func (m Model) statusLeft(width int) string { return styleMuted.Render(truncate("<:> command filter help quit", width)) } +// innerWidth is how wide the box is inside its border: what the table, the +// text and the panel are drawn at. +func (m Model) innerWidth() int { return m.width - 2*screenPadX - 2 } + func (m Model) bodyHeight() int { height := m.height - screenPadTop - headerHeight - statusHeight @@ -615,7 +619,7 @@ func (m *Model) layout() { // The table sits inside the box: the margin, its two border lines and the // header row of the table itself are not rows. - m.list.table.setSize(m.width-2*screenPadX-2, max(m.bodyHeight()-3-m.panelHeight(), 1)) + m.list.table.setSize(m.innerWidth(), max(m.bodyHeight()-3-m.panelHeight(), 1)) grew := false if r, ok := m.screen.page.(reader); ok { @@ -624,7 +628,7 @@ func (m *Model) layout() { m.text.textContent = content } - m.text.setSize(m.width-2*screenPadX-2, max(m.bodyHeight()-2-m.toggleRows()-m.barRows(), 1)) + m.text.setSize(m.innerWidth(), max(m.bodyHeight()-2-m.toggleRows()-m.barRows(), 1)) m.text.filter = m.list.filter m.text.follow() diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index dbf9e5a..192068f 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -1390,3 +1390,9 @@ func newChanges() *fakeChanges { func (f *fakeChanges) stream() *nomad.Changes { return nomad.NewStream(f.c, f.errs, func() { f.closed = true }) } + +func TestInnerWidthIsTheBoxWithoutMarginAndBorder(t *testing.T) { + m := Model{width: 120} + + require.Equal(t, 120-2*screenPadX-2, m.innerWidth()) +} diff --git a/internal/ui/client.go b/internal/ui/client.go index 0664161..dc72679 100644 --- a/internal/ui/client.go +++ b/internal/ui/client.go @@ -297,7 +297,7 @@ func chartHeight(room int) int { // panelHeight is how many rows the panel takes from the table, at the width // it is drawn at. func (m Model) panelHeight() int { - return len(m.panel(m.width - 2*screenPadX - 2)) + return len(m.panel(m.innerWidth())) } // rowsForPanel is what is left of the box once the table has the rows it diff --git a/internal/ui/client_test.go b/internal/ui/client_test.go index 84cbd21..46236a5 100644 --- a/internal/ui/client_test.go +++ b/internal/ui/client_test.go @@ -132,7 +132,7 @@ func TestClient_DrawsWhatTheHostIsDoing(t *testing.T) { // bodyOf is what the box of the screen holds, without its frame. func bodyOf(m Model) string { - _, content := m.body(m.width - 2*screenPadX - 2) + _, content := m.body(m.innerWidth()) return plain(content) } @@ -289,7 +289,7 @@ func TestClient_ChartsShareOneScaleWidth(t *testing.T) { // The first plot row carries no labels: its first bar per half is the // axis both scales stand on. Cells, not bytes: the bar takes three of // those. The table draws the panel without its margin space. - half := chartWidth(m.width - 2*screenPadX - 2) + half := chartWidth(m.innerWidth()) plot := strings.TrimSuffix(strings.TrimPrefix(lines[head+1], " │ "), " │") cells := []rune(plot) cpuAxis := slices.Index(cells[:half], '│') @@ -600,7 +600,7 @@ func TestClient_ComingBackReadsAgain(t *testing.T) { // drawnPanel is how many rows the screen draws above its table. func drawnPanel(m Model) int { - width := m.width - 2*screenPadX - 2 + width := m.innerWidth() _, content := m.body(width) return strings.Count(content, "\n") - strings.Count(m.withHint(m.list.table.view(), width), "\n") @@ -633,7 +633,7 @@ func TestClient_ThePanelTakesWhatTheBoxHasRoomFor(t *testing.T) { {width: 20, box: 40, panel: 2}, } { sized, _ := m.update(tea.WindowSizeMsg{Width: tc.width, Height: tc.box + screenPadTop + headerHeight + statusHeight}) - _, content := sized.body(sized.width - 2*screenPadX - 2) + _, content := sized.body(sized.innerWidth()) body := plain(content) r.Equal(tc.panel, sized.panelHeight(), "%dx%d", tc.width, tc.box) @@ -677,7 +677,7 @@ func TestClient_TheChartSaysHowFarBackItReaches(t *testing.T) { m, _ = m.update(hostUseMsg{nodeID: "node-1", use: nomad.ResourceUse{CPUPercent: 29, CPUTicks: 1165}}) m, _ = m.update(tea.WindowSizeMsg{Width: 114, Height: 44}) - half := chartWidth(m.width - 2*screenPadX - 2) + half := chartWidth(m.innerWidth()) // The left edge is as many readings back as the plot is wide, and the // readings come at the pace of their own timer. The plot is what the diff --git a/internal/ui/screen.go b/internal/ui/screen.go index 1d57319..35a9f46 100644 --- a/internal/ui/screen.go +++ b/internal/ui/screen.go @@ -89,7 +89,7 @@ func (m Model) bar(width int) []string { // barRows is how many rows of the box the question at the foot of the page // takes. func (m Model) barRows() int { - return len(m.bar(m.width - 2*screenPadX - 2)) + return len(m.bar(m.innerWidth())) } // took stores what the open page made of an answer, and applies what it From 52bea80ad2c0d42fee5eafaad16315a833b145e5 Mon Sep 17 00:00:00 2001 From: Igor Churmeev Date: Sat, 17 Oct 2026 12:46:48 +0000 Subject: [PATCH 4/5] refactor(ui): indent the rows of a panel by the margin of the table --- internal/ui/allocpanel.go | 28 +++++++++++++++++++--------- internal/ui/allocpanel_test.go | 23 +++++++++++++++++++++++ internal/ui/client.go | 6 +++--- internal/ui/deployment.go | 10 +++------- internal/ui/plugins.go | 6 +++--- internal/ui/token.go | 2 +- internal/ui/variable.go | 4 ++-- internal/ui/volumes.go | 8 +------- internal/ui/watch_deploy.go | 4 ++-- 9 files changed, 57 insertions(+), 34 deletions(-) diff --git a/internal/ui/allocpanel.go b/internal/ui/allocpanel.go index de9cfa4..963f7d3 100644 --- a/internal/ui/allocpanel.go +++ b/internal/ui/allocpanel.go @@ -64,6 +64,23 @@ func openFollowUp(p tasksPage, e env) (tasksPage, outcome) { // field is a label and its value on a line of a panel. type field struct{ label, value string } +// panelRow starts a row of a panel where the columns of the table do. +func panelRow(row string) string { return strings.Repeat(" ", tableIndent) + row } + +// fieldLineRows are the rows of a panel for its lines of fields, cut to the +// width left after the margin. A line where no field has a value is left out. +func fieldLineRows(lines [][]field, width int) []string { + rows := []string{} + + for _, line := range lines { + if row := fieldLine(line, width-tableIndent); row != "" { + rows = append(rows, panelRow(row)) + } + } + + return rows +} + // allocPanel is what the tasks of an allocation show above them: its status, // client, job version and deployment health, its ports, the allocations // before and after it, and its checks. A line with no values is left out, @@ -112,14 +129,7 @@ func allocPanel(alloc nomad.Alloc, checks []nomad.Check, width, room int) []stri }, } - rows := []string{} - - for _, line := range lines { - if row := fieldLine(line, width-1); row != "" { - // The panel starts where the columns of the table do. - rows = append(rows, " "+row) - } - } + rows := fieldLineRows(lines, width) return fitPanel(rows, checkBlock(checks, width), room) } @@ -224,7 +234,7 @@ func checkBlock(checks []nomad.Check, width int) panelBlock { } return panelBlock{ - title: []string{" " + styleLabel.Render("Checks")}, + title: []string{panelRow(styleLabel.Render("Checks"))}, entries: entries, more: func(count int) string { return fmt.Sprintf(" + %d more checks", count) }, } diff --git a/internal/ui/allocpanel_test.go b/internal/ui/allocpanel_test.go index b56a5ab..13e78f5 100644 --- a/internal/ui/allocpanel_test.go +++ b/internal/ui/allocpanel_test.go @@ -5,6 +5,7 @@ import ( "testing" tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/stretchr/testify/require" "github.com/ingvarch/urga/internal/nomad" @@ -239,3 +240,25 @@ func TestFitPanel_LeavesTheHeadItWasGiven(t *testing.T) { r.Equal([]string{"status", "kept"}, backing) r.Equal("status", rows[0]) } + +func TestPanelRow_StartsWhereTheTableDoes(t *testing.T) { + require.Equal(t, strings.Repeat(" ", tableIndent)+"row", panelRow("row")) +} + +func TestFieldLineRows(t *testing.T) { + lines := [][]field{ + {{"Status", "running"}}, + {{"Ports", ""}}, + {{"Client", "node-1"}}, + } + + rows := fieldLineRows(lines, 40) + + require.Len(t, rows, 2, "a line with no value is left out") + require.Equal(t, panelRow(fieldLine(lines[0], 40-tableIndent)), rows[0]) + require.Equal(t, panelRow(fieldLine(lines[2], 40-tableIndent)), rows[1]) + + for _, row := range fieldLineRows(lines, 8) { + require.LessOrEqual(t, lipgloss.Width(row), 8) + } +} diff --git a/internal/ui/client.go b/internal/ui/client.go index dc72679..bf726ce 100644 --- a/internal/ui/client.go +++ b/internal/ui/client.go @@ -275,7 +275,7 @@ func (h hostModel) keep(use nomad.ResourceUse) hostModel { // of it, which keeps a column for the margin of the table and a gap between // them. func chartWidth(width int) int { - return max((width-1-columnGap)/2, 1) + return max((width-tableIndent-columnGap)/2, 1) } // chartHeight is how tall the plot of a chart is in a panel of room rows. A @@ -337,7 +337,7 @@ func (p clientPage) panel(_ env, width, room int) []string { // is half wide and plot tall, and a plot of no height leaves them out. func (h hostModel) view(width, half, plot int, absolute bool) []string { // One column of the panel goes to the margin the table keeps. - width-- + width -= tableIndent rows := []string{h.details(width), ""} @@ -360,7 +360,7 @@ func (h hostModel) view(width, half, plot int, absolute bool) []string { // The panel starts where the columns of the table do. for i, row := range rows { - rows[i] = " " + row + rows[i] = panelRow(row) } return rows diff --git a/internal/ui/deployment.go b/internal/ui/deployment.go index 133b968..7c1738a 100644 --- a/internal/ui/deployment.go +++ b/internal/ui/deployment.go @@ -422,14 +422,10 @@ func fetchDeployment(client deploymentsClient, namespace, deploymentID string) t // deploymentPanel is the panel above the allocations of a deployment: what // the deployment is, and how far it got with each group. func deploymentPanel(d nomad.DeploymentDetail, width, room int) []string { - head := []string{} - - if row := fieldLine([]field{{"Status", d.Status}, {"Job", d.JobID}, {"Version", fmt.Sprint(d.JobVersion)}}, width-1); row != "" { - head = append(head, " "+row) - } + head := fieldLineRows([][]field{{{"Status", d.Status}, {"Job", d.JobID}, {"Version", fmt.Sprint(d.JobVersion)}}}, width) if d.StatusDescription != "" { - head = append(head, " "+styleMuted.Render(truncate(d.StatusDescription, width-1))) + head = append(head, panelRow(styleMuted.Render(truncate(d.StatusDescription, width-tableIndent)))) } return fitPanel(head, groupBlock(d.Groups, d.Active(), width), room) @@ -459,7 +455,7 @@ func groupBlock(groups []nomad.DeploymentGroup, active bool, width int) panelBlo padded[i] = pad(cell, widths[i]) } - return truncate(" "+strings.TrimRight(strings.Join(padded, strings.Repeat(" ", columnGap)), " "), width) + return truncate(panelRow(strings.TrimRight(strings.Join(padded, strings.Repeat(" ", columnGap)), " ")), width) } entries := make([][]string, 0, len(groups)) diff --git a/internal/ui/plugins.go b/internal/ui/plugins.go index 5a12778..8ad67a7 100644 --- a/internal/ui/plugins.go +++ b/internal/ui/plugins.go @@ -165,14 +165,14 @@ func (p pluginPage) panel(_ env, width, room int) []string { return nil } - line := fieldLine([]field{ + line := panelRow(fieldLine([]field{ {"Provider", strings.TrimSpace(p.plugin.Provider + " " + p.plugin.Version)}, {"Controller required", yesNo(p.plugin.ControllerRequired)}, {"Controllers", healthOf(p.plugin.Controllers)}, {"Nodes", healthOf(p.plugin.Nodes)}, - }, width-1) + }, width-tableIndent)) - return fitPanel([]string{" " + line}, panelBlock{}, room) + return fitPanel([]string{line}, panelBlock{}, room) } func (p pluginPage) rows(env) []tableRow { diff --git a/internal/ui/token.go b/internal/ui/token.go index ca89c3d..5075774 100644 --- a/internal/ui/token.go +++ b/internal/ui/token.go @@ -145,7 +145,7 @@ func (m Model) withHint(table string, width int) string { return table } - return table + "\n " + styleMuted.Render(truncate(hint, width-1)) + return table + "\n" + panelRow(styleMuted.Render(truncate(hint, width-tableIndent))) } // managementToken is the type of a token that may do anything. diff --git a/internal/ui/variable.go b/internal/ui/variable.go index 297724c..d9625bc 100644 --- a/internal/ui/variable.go +++ b/internal/ui/variable.go @@ -237,8 +237,8 @@ func (p variablePage) panel(_ env, width, room int) []string { } head := []string{ - " " + fieldLine([]field{{"Lock", lock.ID}, {"TTL", lock.TTL}, {"Delay", lock.Delay}}, width-1), - " " + styleMuted.Render(truncate("Only the holder of the lock can change it.", width-1)), + panelRow(fieldLine([]field{{"Lock", lock.ID}, {"TTL", lock.TTL}, {"Delay", lock.Delay}}, width-tableIndent)), + panelRow(styleMuted.Render(truncate("Only the holder of the lock can change it.", width-tableIndent))), } return fitPanel(head, panelBlock{}, room) diff --git a/internal/ui/volumes.go b/internal/ui/volumes.go index 1405dfe..f8cafd2 100644 --- a/internal/ui/volumes.go +++ b/internal/ui/volumes.go @@ -308,13 +308,7 @@ func volumePanel(v nomad.VolumeDetail, width, room int) []string { lines = csiPanelLines(v) } - rows := []string{} - - for _, line := range lines { - if row := fieldLine(line, width-1); row != "" { - rows = append(rows, " "+row) - } - } + rows := fieldLineRows(lines, width) return fitPanel(rows, panelBlock{}, room) } diff --git a/internal/ui/watch_deploy.go b/internal/ui/watch_deploy.go index 625719c..376099b 100644 --- a/internal/ui/watch_deploy.go +++ b/internal/ui/watch_deploy.go @@ -168,10 +168,10 @@ func (p deployWatchPage) panel(_ env, width, room int) []string { head := []string{} if p.eval != nil { - head = append(head, " "+watchedEvaluation(*p.eval, width-1)) + head = append(head, panelRow(watchedEvaluation(*p.eval, width-tableIndent))) } - head = append(head, " "+truncate(p.standing(), width-1)) + head = append(head, panelRow(truncate(p.standing(), width-tableIndent))) return fitPanel(head, groupBlock(p.deployment.deployment.Groups, p.deployment.active(), width), room) } From bd72862979d75f50caa514c89efc4325c4f1d992 Mon Sep 17 00:00:00 2001 From: Igor Churmeev Date: Sat, 17 Oct 2026 12:48:30 +0000 Subject: [PATCH 5/5] refactor(ui): drop the flag and the empty callback of applyWhen --- internal/ui/app.go | 21 ++++++++++----------- internal/ui/namespace.go | 2 +- internal/ui/region.go | 4 ++-- internal/ui/region_test.go | 27 +++++++++++++++++++++++++++ internal/ui/screen.go | 5 ++--- 5 files changed, 42 insertions(+), 17 deletions(-) diff --git a/internal/ui/app.go b/internal/ui/app.go index 1dcb177..a4063fc 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -283,10 +283,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return next, cmd } -// applyList stores what the cluster sent, unless the screen it belongs to -// was left: it would show up under the wrong title. -func (m Model) applyList(v *view, store func(*Model)) (Model, tea.Cmd) { - return m.applyWhen(m.screen.view == v, store) +// applyList shows what the cluster sent, the caller having stored it, unless +// the screen it belongs to was left: it would show up under the wrong title. +func (m Model) applyList(v *view) (Model, tea.Cmd) { + if m.screen.view != v { + return m, nil + } + + return m.markAnswered() } // takeError shows the error. The rows that are on the screen stay there: @@ -305,13 +309,8 @@ func (m Model) takeError(err error) (Model, tea.Cmd) { return m.fail(err).schedulePoll() } -// applyWhen stores an answer for what is open, and schedules the next poll. -func (m Model) applyWhen(ours bool, store func(*Model)) (Model, tea.Cmd) { - if !ours { - return m, nil - } - - store(&m) +// markAnswered marks what is open as answered, and schedules the next poll. +func (m Model) markAnswered() (Model, tea.Cmd) { m.answered = true m = m.forget() m.layout() diff --git a/internal/ui/namespace.go b/internal/ui/namespace.go index 3678a8b..1f792c4 100644 --- a/internal/ui/namespace.go +++ b/internal/ui/namespace.go @@ -121,7 +121,7 @@ func (m Model) keepNamespaces(msg namespacesMsg) (Model, tea.Cmd) { m.namespaces = msg m.rememberNamespaces(msg) - next, cmd := m.applyList(namespacesView, func(*Model) {}) + next, cmd := m.applyList(namespacesView) return next, tea.Batch(cmd, next.remember()) } diff --git a/internal/ui/region.go b/internal/ui/region.go index c2b2e4d..20d3ed1 100644 --- a/internal/ui/region.go +++ b/internal/ui/region.go @@ -37,7 +37,7 @@ func (m Model) keepRegions(names regionsMsg) (Model, tea.Cmd) { // against them. m.regions = names - return m.applyList(regionsView, func(*Model) {}) + return m.applyList(regionsView) } // keepDatacenters keeps the datacenters of the region in use. @@ -49,7 +49,7 @@ func (m Model) keepDatacenters(msg datacentersMsg) (Model, tea.Cmd) { m.datacenters = msg.names - return m.applyList(datacentersView, func(*Model) {}) + return m.applyList(datacentersView) } // regionCommand switches to a region by name, or opens the list of them to diff --git a/internal/ui/region_test.go b/internal/ui/region_test.go index 349024f..37fa736 100644 --- a/internal/ui/region_test.go +++ b/internal/ui/region_test.go @@ -729,3 +729,30 @@ func TestRegionState_NarrowsToItsDatacenter(t *testing.T) { // No datacenter chosen is every one of them. r.True(regionState{}.inDatacenter("dc1")) } + +func TestAnswerOfAnotherScreen_ChangesNothing(t *testing.T) { + answers := map[string]tea.Msg{ + "regions": regionsMsg([]string{"eu", "us"}), + "datacenters": datacentersMsg{region: "", names: []string{"dc1"}}, + "namespaces": namespacesMsg(threeNamespaces()), + } + + for name, msg := range answers { + t.Run(name, func(t *testing.T) { + r := require.New(t) + + m := newTestModel(&fakeClient{}) + m.answered = false + m = m.fail(errors.New("boom")) + + m, cmd := m.update(msg) + + r.False(m.answered) + r.Contains(m.flash.text, "boom") + + if name != "namespaces" { + r.Nil(cmd) + } + }) + } +} diff --git a/internal/ui/screen.go b/internal/ui/screen.go index 35a9f46..ae22840 100644 --- a/internal/ui/screen.go +++ b/internal/ui/screen.go @@ -98,14 +98,13 @@ func (m Model) barRows() int { // timer of the page does not. func (m Model) took(next page, out outcome) (Model, tea.Cmd) { out.cmd = askedFor(m.asked, out.cmd) + m.screen.page = next if out.reading { - m.screen.page = next - return m.apply(out) } - m, poll := m.applyWhen(true, func(m *Model) { m.screen.page = next }) + m, poll := m.markAnswered() m, cmd := m.apply(out) // Rows that show their resource usage are read once they are filled.