From c1447ce8c7eebbc33f5fb53bf5cf39720eafb845 Mon Sep 17 00:00:00 2001 From: Akshay Singla Date: Fri, 29 May 2026 22:11:34 +0000 Subject: [PATCH 1/2] lakebox: always show NAME column in `lakebox list` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Yunquan flagged on the bug-bash form: the table shape changed between calls — the NAME column appeared the moment any sandbox had a custom `--name` set, and vanished when none did. Scripts that parsed `list` output had to handle two column layouts; users had to mentally remap columns based on workspace state. Always render the column. Sandboxes without a custom name (Name == "" or Name == SandboxID) display `-` (faint), same convention we already use elsewhere. The column width still scales to the longest *actual* name, so workspaces with only unnamed sandboxes render a narrow NAME column of dashes — visually quiet but structurally present. Co-authored-by: Isaac --- cmd/lakebox/list.go | 81 ++++++++++++++++++++------------------------- 1 file changed, 35 insertions(+), 46 deletions(-) diff --git a/cmd/lakebox/list.go b/cmd/lakebox/list.go index 7e951ffd7be..d6cab3af2bd 100644 --- a/cmd/lakebox/list.go +++ b/cmd/lakebox/list.go @@ -98,10 +98,16 @@ Example: out := cmd.OutOrStdout() // Compute column widths. AUTOSTOP holds short tokens like - // `never`, `15m`, `1h30m` — 8 chars covers them. NAME is - // rendered only when at least one entry sets a display name - // different from the ID — there's no point in a column of - // pet-names that duplicate the ID column. + // `never`, `15m`, `1h30m` — 8 chars covers them. + // + // NAME is *always* rendered, even when no sandbox has a + // custom --name set: yunquan flagged on the bug-bash form + // that the prior auto-hide made the table shape change + // between calls (NAME appears the moment you set --name on + // any one box and vanishes when you clear them all), which + // breaks scripts and muscle memory. Sandboxes without a + // custom name render as `-` in the NAME cell. + // // All column widths are measured in *terminal cells*, not // bytes or runes — emoji and CJK glyphs render as 2 cells // despite being 1 rune / multi-byte, and using len() here @@ -111,7 +117,6 @@ Example: idCol := 10 autostopCol := 8 nameCol := 4 - showName := false for _, e := range entries { if l := runewidth.StringWidth(e.SandboxID); l > idCol { idCol = l @@ -119,36 +124,28 @@ Example: if l := runewidth.StringWidth(e.autoStopLabel()); l > autostopCol { autostopCol = l } + // Only let an actual custom name expand the column. A + // sandbox whose `name` happens to equal its `id` would + // otherwise drive the column to the ID's width — for no + // gain, since that row renders as `-`. if e.Name != "" && e.Name != e.SandboxID { - showName = true - } - if l := runewidth.StringWidth(e.Name); l > nameCol { - nameCol = l + if l := runewidth.StringWidth(e.Name); l > nameCol { + nameCol = l + } } } idCol += 2 autostopCol += 2 - if showName { - nameCol += 2 - } + nameCol += 2 const statusCol = 10 const defaultCol = 7 blank(out) - var header string - if showName { - header = fmt.Sprintf("%-*s %-*s %-*s %-*s %s", - idCol, "ID", nameCol, "NAME", statusCol, "STATUS", autostopCol, "AUTOSTOP", "DEFAULT") - } else { - header = fmt.Sprintf("%-*s %-*s %-*s %s", - idCol, "ID", statusCol, "STATUS", autostopCol, "AUTOSTOP", "DEFAULT") - } + header := fmt.Sprintf("%-*s %-*s %-*s %-*s %s", + idCol, "ID", nameCol, "NAME", statusCol, "STATUS", autostopCol, "AUTOSTOP", "DEFAULT") fmt.Fprintf(out, " %s\n", cmdio.Faint(ctx, header)) - ruleLen := idCol + statusCol + autostopCol + defaultCol + 6 - if showName { - ruleLen += nameCol + 2 - } + ruleLen := idCol + nameCol + statusCol + autostopCol + defaultCol + 8 fmt.Fprintf(out, " %s\n", cmdio.Faint(ctx, strings.Repeat("─", ruleLen))) for _, e := range entries { @@ -168,29 +165,21 @@ Example: if strings.EqualFold(e.Status, "running") { idStr = cmdio.Bold(ctx, cmdio.Cyan(ctx, id)) } - if showName { - nm := e.Name - if nm == "" || nm == id { - nm = "-" - } - nmPad := max(nameCol-runewidth.StringWidth(nm), 0) - nmStr := nm - if nm == "-" { - nmStr = cmdio.Faint(ctx, "-") - } - fmt.Fprintf(out, " %s%s %s%s %s%s %s%s %s\n", - idStr, strings.Repeat(" ", idPad), - nmStr, strings.Repeat(" ", nmPad), - st, strings.Repeat(" ", stPad), - cmdio.Faint(ctx, as), strings.Repeat(" ", asPad), - def) - } else { - fmt.Fprintf(out, " %s%s %s%s %s%s %s\n", - idStr, strings.Repeat(" ", idPad), - st, strings.Repeat(" ", stPad), - cmdio.Faint(ctx, as), strings.Repeat(" ", asPad), - def) + nm := e.Name + if nm == "" || nm == id { + nm = "-" + } + nmPad := max(nameCol-runewidth.StringWidth(nm), 0) + nmStr := nm + if nm == "-" { + nmStr = cmdio.Faint(ctx, "-") } + fmt.Fprintf(out, " %s%s %s%s %s%s %s%s %s\n", + idStr, strings.Repeat(" ", idPad), + nmStr, strings.Repeat(" ", nmPad), + st, strings.Repeat(" ", stPad), + cmdio.Faint(ctx, as), strings.Repeat(" ", asPad), + def) } blank(out) return nil From 989359aed86d78b25a2c68071f0f56da5349d8f6 Mon Sep 17 00:00:00 2001 From: Akshay Singla Date: Tue, 2 Jun 2026 06:10:01 +0000 Subject: [PATCH 2/2] lakebox: render `list` and `ssh-key list` via cmdio.RenderWithTemplate Switches both lakebox table commands from hand-rolled `runewidth` padding to the standard `cmd.Annotations["template"]` + `cmdio.Render` pipeline used everywhere else in the CLI (see e.g. `cmd/workspace/clusters`). The local `--json` flag is dropped in favor of the framework-wide `-o json`. Trade-offs accepted to conform: - Status text now reflects server-side casing ("Stopped") instead of lowercased ("stopped"). - Column widths are byte-based via `text/tabwriter`; emoji / CJK characters in `--name` may misalign rows, matching every other CLI list today. - The "stopped" status loses its faint-gray treatment; all states share the same color family (green / yellow / blue) so the STATUS column stays aligned under mixed-color rows. Drops `mattn/go-runewidth` from the direct require list and the NOTICE file (it's still present as an indirect dep via charmbracelet). Co-authored-by: Isaac --- NOTICE | 4 -- cmd/lakebox/list.go | 141 ++++++++++++++---------------------------- cmd/lakebox/sshkey.go | 117 ++++++++++++++++------------------- go.mod | 2 +- 4 files changed, 102 insertions(+), 162 deletions(-) diff --git a/NOTICE b/NOTICE index fb80040b33a..01f9df98246 100644 --- a/NOTICE +++ b/NOTICE @@ -151,10 +151,6 @@ mattn/go-isatty - https://github.com/mattn/go-isatty Copyright (c) Yasuhiro MATSUMOTO License - https://github.com/mattn/go-isatty/blob/master/LICENSE -mattn/go-runewidth - https://github.com/mattn/go-runewidth -Copyright (c) 2016 Yasuhiro Matsumoto -License - https://github.com/mattn/go-runewidth/blob/master/LICENSE - sabhiram/go-gitignore - https://github.com/sabhiram/go-gitignore Copyright (c) 2015 Shaba Abhiram License - https://github.com/sabhiram/go-gitignore/blob/master/LICENSE diff --git a/cmd/lakebox/list.go b/cmd/lakebox/list.go index d6cab3af2bd..5788693d31d 100644 --- a/cmd/lakebox/list.go +++ b/cmd/lakebox/list.go @@ -1,20 +1,35 @@ package lakebox import ( - "encoding/json" "fmt" - "strings" "github.com/databricks/cli/cmd/root" "github.com/databricks/cli/libs/cmdctx" "github.com/databricks/cli/libs/cmdio" - "github.com/mattn/go-runewidth" + "github.com/databricks/cli/libs/flags" "github.com/spf13/cobra" ) -func newListCommand() *cobra.Command { - var outputJSON bool +// listRow embeds sandboxEntry so JSON output stays byte-identical to the +// raw API response. Text-mode fields are tagged `json:"-"` so they don't +// leak when the user passes `-o json`. +type listRow struct { + sandboxEntry + DisplayName string `json:"-"` + AutoStop string `json:"-"` + Default string `json:"-"` +} + +// State colors are picked from cmdio's RenderFuncMap palette. green, +// yellow, and blue all emit same-byte-width SGR sequences, so the STATUS +// column stays aligned under tabwriter even when colors vary per row. +const ( + listHeaderTemplate = `{{header "ID"}} {{header "NAME"}} {{header "STATUS"}} {{header "AUTOSTOP"}} {{header "DEFAULT"}}` + listRowTemplate = `{{range .}}{{.SandboxID | bold}} {{.DisplayName}} {{if eq .Status "Running"}}{{green "%s" .Status}}{{else if eq .Status "Creating"}}{{yellow "%s" .Status}}{{else}}{{blue "%s" .Status}}{{end}} {{.AutoStop | faint}} {{.Default | cyan}} +{{end}}` +) +func newListCommand() *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "List your Lakebox environments", @@ -25,8 +40,12 @@ current status and ID. Example: databricks lakebox list - databricks lakebox list --json`, + databricks lakebox list -o json`, PreRunE: root.MustWorkspaceClient, + Annotations: map[string]string{ + "headerTemplate": listHeaderTemplate, + "template": listRowTemplate, + }, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() w := cmdctx.WorkspaceClient(ctx) @@ -84,10 +103,10 @@ Example: } _ = setSandboxes(ctx, profile, refs) - if jsonOutput(cmd, outputJSON) { - enc := json.NewEncoder(cmd.OutOrStdout()) - enc.SetIndent("", " ") - return enc.Encode(entries) + // JSON path: emit the raw entries (not the display-row wrapper) + // so consumers see the same shape as the underlying API. + if root.OutputType(cmd) == flags.OutputJSON { + return cmdio.Render(ctx, entries) } if len(entries) == 0 { @@ -95,98 +114,30 @@ Example: return nil } - out := cmd.OutOrStdout() - - // Compute column widths. AUTOSTOP holds short tokens like - // `never`, `15m`, `1h30m` — 8 chars covers them. - // - // NAME is *always* rendered, even when no sandbox has a - // custom --name set: yunquan flagged on the bug-bash form - // that the prior auto-hide made the table shape change - // between calls (NAME appears the moment you set --name on - // any one box and vanishes when you clear them all), which - // breaks scripts and muscle memory. Sandboxes without a - // custom name render as `-` in the NAME cell. - // - // All column widths are measured in *terminal cells*, not - // bytes or runes — emoji and CJK glyphs render as 2 cells - // despite being 1 rune / multi-byte, and using len() here - // (which counts bytes) misaligns the row whenever a `--name` - // includes wide characters. runewidth.StringWidth gives the - // East-Asian-Width-corrected cell count. - idCol := 10 - autostopCol := 8 - nameCol := 4 - for _, e := range entries { - if l := runewidth.StringWidth(e.SandboxID); l > idCol { - idCol = l - } - if l := runewidth.StringWidth(e.autoStopLabel()); l > autostopCol { - autostopCol = l + rows := make([]listRow, len(entries)) + for i, e := range entries { + // "-" stands in for an unset NAME (or a NAME that just + // echoes the ID and so carries no extra information). + // Keep it ASCII so it doesn't add an ANSI wrapper that + // would throw off the column. + name := e.Name + if name == "" || name == e.SandboxID { + name = "-" } - // Only let an actual custom name expand the column. A - // sandbox whose `name` happens to equal its `id` would - // otherwise drive the column to the ID's width — for no - // gain, since that row renders as `-`. - if e.Name != "" && e.Name != e.SandboxID { - if l := runewidth.StringWidth(e.Name); l > nameCol { - nameCol = l - } - } - } - idCol += 2 - autostopCol += 2 - nameCol += 2 - const statusCol = 10 - const defaultCol = 7 - - blank(out) - header := fmt.Sprintf("%-*s %-*s %-*s %-*s %s", - idCol, "ID", nameCol, "NAME", statusCol, "STATUS", autostopCol, "AUTOSTOP", "DEFAULT") - fmt.Fprintf(out, " %s\n", cmdio.Faint(ctx, header)) - - ruleLen := idCol + nameCol + statusCol + autostopCol + defaultCol + 8 - fmt.Fprintf(out, " %s\n", cmdio.Faint(ctx, strings.Repeat("─", ruleLen))) - - for _, e := range entries { - id := e.SandboxID def := "" - if id == defaultID { - def = cmdio.Cyan(ctx, "*") - } - // Pad each cell manually so visible-width alignment is - // preserved after the helpers wrap them with ANSI escapes. - idPad := max(idCol-runewidth.StringWidth(id), 0) - st := status(ctx, e.Status) - stPad := max(statusCol-runewidth.StringWidth(e.Status), 0) - as := e.autoStopLabel() - asPad := max(autostopCol-runewidth.StringWidth(as), 0) - idStr := cmdio.Bold(ctx, id) - if strings.EqualFold(e.Status, "running") { - idStr = cmdio.Bold(ctx, cmdio.Cyan(ctx, id)) + if e.SandboxID == defaultID { + def = "*" } - nm := e.Name - if nm == "" || nm == id { - nm = "-" + rows[i] = listRow{ + sandboxEntry: e, + DisplayName: name, + AutoStop: e.autoStopLabel(), + Default: def, } - nmPad := max(nameCol-runewidth.StringWidth(nm), 0) - nmStr := nm - if nm == "-" { - nmStr = cmdio.Faint(ctx, "-") - } - fmt.Fprintf(out, " %s%s %s%s %s%s %s%s %s\n", - idStr, strings.Repeat(" ", idPad), - nmStr, strings.Repeat(" ", nmPad), - st, strings.Repeat(" ", stPad), - cmdio.Faint(ctx, as), strings.Repeat(" ", asPad), - def) } - blank(out) - return nil + return cmdio.Render(ctx, rows) }, } - cmd.Flags().BoolVar(&outputJSON, "json", false, "Output as JSON") - return cmd } diff --git a/cmd/lakebox/sshkey.go b/cmd/lakebox/sshkey.go index 5b7f3829aa9..1f0625606c2 100644 --- a/cmd/lakebox/sshkey.go +++ b/cmd/lakebox/sshkey.go @@ -1,16 +1,14 @@ package lakebox import ( - "encoding/json" "fmt" "os" - "strings" "time" "github.com/databricks/cli/cmd/root" "github.com/databricks/cli/libs/cmdctx" "github.com/databricks/cli/libs/cmdio" - "github.com/mattn/go-runewidth" + "github.com/databricks/cli/libs/flags" "github.com/spf13/cobra" ) @@ -61,9 +59,24 @@ Example: return cmd } -func newSSHKeyListCommand() *cobra.Command { - var outputJSON bool +// sshKeyRow embeds sshKeyEntry so JSON output stays byte-identical to +// the raw API response. Text-mode fields are tagged `json:"-"` so they +// don't leak when the user passes `-o json`. +type sshKeyRow struct { + sshKeyEntry + DisplayName string `json:"-"` + Created string `json:"-"` + LastUsed string `json:"-"` + Local string `json:"-"` +} +const ( + sshKeyHeaderTemplate = `{{header "LOCAL"}} {{header "NAME"}} {{header "KEY HASH"}} {{header "CREATED"}} {{header "LAST USED"}}` + sshKeyRowTemplate = `{{range .}}{{.Local | cyan}} {{.DisplayName}} {{.KeyHash}} {{.Created | faint}} {{.LastUsed | faint}} +{{end}}` +) + +func newSSHKeyListCommand() *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "List SSH keys registered with the lakebox service", @@ -72,12 +85,16 @@ func newSSHKeyListCommand() *cobra.Command { Each row shows the server-assigned key hash (the identifier used to delete the key), the user-supplied name, and create / last-use timestamps. The locally-registered key (from 'databricks lakebox -register') is marked when its hash matches one of the listed entries. +register') is marked with a '*' in the LOCAL column. Examples: databricks lakebox ssh-key list - databricks lakebox ssh-key list --json`, + databricks lakebox ssh-key list -o json`, PreRunE: root.MustWorkspaceClient, + Annotations: map[string]string{ + "headerTemplate": sshKeyHeaderTemplate, + "template": sshKeyRowTemplate, + }, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() w := cmdctx.WorkspaceClient(ctx) @@ -91,10 +108,8 @@ Examples: return fmt.Errorf("failed to list ssh keys: %w", err) } - if jsonOutput(cmd, outputJSON) { - enc := json.NewEncoder(cmd.OutOrStdout()) - enc.SetIndent("", " ") - return enc.Encode(keys) + if root.OutputType(cmd) == flags.OutputJSON { + return cmdio.Render(ctx, keys) } if len(keys) == 0 { @@ -103,9 +118,10 @@ Examples: return nil } - // Best-effort: compute the hash of the locally-registered key so - // we can highlight which row belongs to this machine. Missing key - // file or read errors are non-fatal — just skip the marker. + // Best-effort: compute the hash of the locally-registered key + // so we can highlight which row belongs to this machine. + // Missing key file or read errors are non-fatal — just skip + // the marker. localHash := "" if path, err := lakeboxKeyPath(ctx); err == nil { if data, err := os.ReadFile(path + ".pub"); err == nil { @@ -113,68 +129,45 @@ Examples: } } - out := cmd.OutOrStdout() - blank(out) - - // Measure in terminal cells (runewidth) so wide / emoji - // glyphs in `--name` don't misalign the row. - nameCol := 4 - for _, k := range keys { - if l := runewidth.StringWidth(k.Name); l > nameCol { - nameCol = l - } - } - nameCol += 2 - const hashCol = 32 - const timeCol = 20 - - // Leading 4-char gutter reserves space for a per-row `*` marker on - // the key matching this machine; header and separator leave it blank. - header := fmt.Sprintf("%-*s %-*s %-*s %s", - nameCol, "NAME", hashCol, "KEY HASH", timeCol, "CREATED", "LAST USED") - fmt.Fprintf(out, " %s\n", cmdio.Faint(ctx, header)) - fmt.Fprintf(out, " %s\n", cmdio.Faint(ctx, strings.Repeat("─", nameCol+hashCol+timeCol+timeCol+6))) - + rows := make([]sshKeyRow, len(keys)) localFound := false - for _, k := range keys { - // Pad NAME manually from the raw width because cmdio.Faint - // wraps the cell in ANSI escapes that throw off `%-*s`. - displayName, visibleNameLen := k.Name, runewidth.StringWidth(k.Name) - if displayName == "" { - displayName = cmdio.Faint(ctx, "(unset)") - visibleNameLen = runewidth.StringWidth("(unset)") + for i, k := range keys { + name := k.Name + if name == "" { + name = "-" } - namePad := max(nameCol-visibleNameLen, 0) - gutter := " " + local := "" if localHash != "" && k.KeyHash == localHash { - gutter = " " + cmdio.Cyan(ctx, "*") + " " + local = "*" localFound = true } - fmt.Fprintf(out, "%s%s%s %-*s %-*s %s\n", - gutter, - displayName, strings.Repeat(" ", namePad), - hashCol, k.KeyHash, - timeCol, formatTimeShort(k.CreateTime), - formatTimeShort(k.LastUseTime)) + rows[i] = sshKeyRow{ + sshKeyEntry: k, + DisplayName: name, + Created: formatTimeShort(k.CreateTime), + LastUsed: formatTimeShort(k.LastUseTime), + Local: local, + } + } + + if err := cmdio.Render(ctx, rows); err != nil { + return err } - // Without a legend the `*` (or its absence) is opaque. Print the - // meaning either way so users can tell "no `*` on any row" apart - // from "this terminal doesn't print the marker". - blank(out) + + // Without a legend the `*` (or its absence) is opaque. Print + // the meaning either way so users can tell "no `*` on any + // row" apart from "this terminal doesn't print the marker". switch { case localFound: - fmt.Fprintf(out, " %s\n", cmdio.Faint(ctx, cmdio.Cyan(ctx, "*")+" key matches the one on this machine")) + cmdio.LogString(ctx, " "+cmdio.Faint(ctx, cmdio.Cyan(ctx, "*")+" key matches the one on this machine")) case localHash != "": - fmt.Fprintf(out, " %s\n", cmdio.Faint(ctx, "(no registered key matches this machine's local key — run `databricks lakebox register` to register it)")) + cmdio.LogString(ctx, " "+cmdio.Faint(ctx, "(no registered key matches this machine's local key — run `databricks lakebox register` to register it)")) default: - fmt.Fprintf(out, " %s\n", cmdio.Faint(ctx, "(no local lakebox key on this machine — run `databricks lakebox register` to create and register one)")) + cmdio.LogString(ctx, " "+cmdio.Faint(ctx, "(no local lakebox key on this machine — run `databricks lakebox register` to create and register one)")) } - blank(out) return nil }, } - - cmd.Flags().BoolVar(&outputJSON, "json", false, "Output as JSON") return cmd } diff --git a/go.mod b/go.mod index ddeceadd2f1..f35dadb31ce 100644 --- a/go.mod +++ b/go.mod @@ -24,7 +24,6 @@ require ( github.com/hexops/gotextdiff v1.0.3 // BSD-3-Clause github.com/jackc/pgx/v5 v5.9.2 // MIT github.com/mattn/go-isatty v0.0.22 // MIT - github.com/mattn/go-runewidth v0.0.23 // MIT github.com/palantir/pkg/yamlpatch v1.5.0 // BSD-3-Clause github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // BSD-2-Clause github.com/quasilyte/go-ruleguard/dsl v0.3.22 // BSD-3-Clause @@ -81,6 +80,7 @@ require ( github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect