From 24853bfce850dc9b96457d6d551d94fa0bb05be6 Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 14:14:02 +0000 Subject: [PATCH] feat: default ttl and header visibility, and removed unnecessray padding from tab output --- README.md | 35 ++++-- cmd/list.go | 224 +++++++++++++++++++++++++++------ cmd/shared.go | 6 +- testdata/help__list__ok.ct | 10 +- testdata/list__glob__ok.ct | 8 +- testdata/remove__dedupe__ok.ct | 18 +-- 6 files changed, 232 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index 146eba4..d3e801e 100644 --- a/README.md +++ b/README.md @@ -192,19 +192,34 @@ pda rm kitty -i `pda ls` to see what you've got stored. ```bash pda ls -# name Alice -# dogs four legged mammals +# KEY VALUE TTL +# name Alice no expiry +# dogs four legged mammals no expiry # Or as CSV. pda ls --format csv -# name,Alice -# dogs,four legged mammals +# Key,Value,TTL +# name,Alice,no expiry +# dogs,four legged mammals,no expiry # Or TSV, or Markdown, or HTML. ```

+Long values are truncated to fit the terminal. Use `--full`/`-f` to show the complete value. +```bash +pda ls +# KEY VALUE TTL +# note this is a very long (..30 more chars) no expiry + +pda ls --full +# KEY VALUE TTL +# note this is a very long value that keeps on going and going no expiry +``` + +

+ `pda export` to export everything as NDJSON. ```bash pda export > my_backup @@ -536,11 +551,12 @@ pda set session2 "xyz" --ttl 54m10s

-`list --ttl` shows expiration date in list output. +`list` shows expiration in the TTL column by default. ```bash -pda ls --ttl -# session 123 2025-11-21T15:30:00Z (in 59m30s) -# session2 xyz 2025-11-21T15:21:40Z (in 51m40s) +pda ls +# KEY VALUE TTL +# session 123 in 59m30s +# session2 xyz in 51m40s ``` `export` and `import` persist the expiry date. Expirations will continue ticking down regardless of if they're actively in a store or not - the expiry is just a timestamp, not a timer. @@ -628,7 +644,8 @@ pda set api-key "oops" If the identity file is missing, encrypted values are inaccessible but not lost. Keys are still visible, and the ciphertext is preserved through reads and writes. ```bash pda ls -# api-key locked (identity file missing) +# KEY VALUE TTL +# api-key locked (identity file missing) no expiry pda get api-key # FAIL cannot get 'api-key': secret is locked (identity file missing) diff --git a/cmd/list.go b/cmd/list.go index b9b4bee..3c6aaea 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -29,6 +29,8 @@ import ( "io" "os" "strconv" + "strings" + "unicode/utf8" "filippo.io/age" "github.com/jedib0t/go-pretty/v6/table" @@ -58,9 +60,12 @@ var ( listBase64 bool listNoKeys bool listNoValues bool - listTTL bool - listHeader bool - listFormat formatEnum = "table" + listNoTTL bool + listFull bool + listNoHeader bool + listFormat formatEnum = "table" + + dimStyle = text.Colors{text.Faint, text.Italic} ) type columnKind int @@ -99,8 +104,8 @@ func list(cmd *cobra.Command, args []string) error { targetDB = "@" + dbName } - if listNoKeys && listNoValues && !listTTL { - return withHint(fmt.Errorf("cannot ls '%s': no columns selected", targetDB), "disable --no-keys/--no-values or pass --ttl") + if listNoKeys && listNoValues && listNoTTL { + return withHint(fmt.Errorf("cannot ls '%s': no columns selected", targetDB), "disable --no-keys, --no-values, or --no-ttl") } var columns []columnKind @@ -110,7 +115,7 @@ func list(cmd *cobra.Command, args []string) error { if !listNoValues { columns = append(columns, columnValue) } - if listTTL { + if !listNoTTL { columns = append(columns, columnTTL) } @@ -183,39 +188,131 @@ func list(cmd *cobra.Command, args []string) error { tw.Style().Options.DrawBorder = false tw.Style().Options.SeparateRows = false tw.Style().Options.SeparateColumns = false + tw.Style().Box.PaddingLeft = "" + tw.Style().Box.PaddingRight = " " - if listHeader { + tty := stdoutIsTerminal() && listFormat.String() == "table" + + if !listNoHeader { tw.AppendHeader(headerRow(columns)) + if tty { + tw.Style().Color.Header = text.Colors{text.Bold} + } } + lay := computeLayout(columns, output, filtered) for _, e := range filtered { var valueStr string + dimValue := false if showValues { if e.Locked { valueStr = "locked (identity file missing)" + dimValue = true } else { valueStr = store.FormatBytes(listBase64, e.Value) + if !utf8.Valid(e.Value) && !listBase64 { + dimValue = true + } + } + if !listFull { + valueStr = summariseValue(valueStr, lay.value, tty) } } row := make(table.Row, 0, len(columns)) for _, col := range columns { switch col { case columnKey: - row = append(row, e.Key) + if tty { + row = append(row, text.Bold.Sprint(e.Key)) + } else { + row = append(row, e.Key) + } case columnValue: - row = append(row, valueStr) + if tty && dimValue { + row = append(row, dimStyle.Sprint(valueStr)) + } else { + row = append(row, valueStr) + } case columnTTL: - row = append(row, formatExpiry(e.ExpiresAt)) + ttlStr := formatExpiry(e.ExpiresAt) + if tty && e.ExpiresAt == 0 { + ttlStr = dimStyle.Sprint(ttlStr) + } + row = append(row, ttlStr) } } tw.AppendRow(row) } - applyColumnWidths(tw, columns, output) + applyColumnWidths(tw, columns, output, lay, listFull) renderTable(tw) return nil } +// summariseValue flattens a value to its first line and, when maxWidth > 0, +// truncates to fit. In both cases it appends "(..N more chars)" showing the +// total number of omitted characters. +func summariseValue(s string, maxWidth int, tty bool) string { + first := s + if i := strings.IndexByte(s, '\n'); i >= 0 { + first = s[:i] + } + + totalRunes := utf8.RuneCountInString(s) + firstRunes := utf8.RuneCountInString(first) + + // Nothing omitted and fits (or no width constraint). + if firstRunes == totalRunes && (maxWidth <= 0 || firstRunes <= maxWidth) { + return first + } + + // How many runes of first can we show? + showRunes := firstRunes + if maxWidth > 0 && showRunes > maxWidth { + showRunes = maxWidth + } + + style := func(s string) string { + if tty { + return dimStyle.Sprint(s) + } + return s + } + + // Iteratively make room for the suffix (at most two passes since + // the digit count can change by one at a boundary like 9→10). + for range 2 { + omitted := totalRunes - showRunes + if omitted <= 0 { + return first + } + suffix := fmt.Sprintf(" (..%d more chars)", omitted) + suffixRunes := utf8.RuneCountInString(suffix) + if maxWidth <= 0 { + return first + style(suffix) + } + if showRunes+suffixRunes <= maxWidth { + runes := []rune(first) + if showRunes < len(runes) { + first = string(runes[:showRunes]) + } + return first + style(suffix) + } + avail := maxWidth - suffixRunes + if avail <= 0 { + // Suffix alone exceeds maxWidth; fall through to hard trim. + break + } + showRunes = avail + } + + // Column too narrow for the suffix — just truncate with an ellipsis. + if maxWidth >= 2 { + return text.Trim(first, maxWidth-1) + style("…") + } + return text.Trim(first, maxWidth) +} + func headerRow(columns []columnKind) table.Row { row := make(table.Row, 0, len(columns)) for _, col := range columns { @@ -231,51 +328,95 @@ func headerRow(columns []columnKind) table.Row { return row } -func applyColumnWidths(tw table.Writer, columns []columnKind, out io.Writer) { +const ( + keyColumnWidthCap = 30 + ttlColumnWidthCap = 20 +) + +// columnLayout holds the resolved max widths for each column kind. +type columnLayout struct { + key, value, ttl int +} + +// computeLayout derives column widths from the terminal size and actual +// content widths of the key/TTL columns (capped at fixed maximums). This +// avoids reserving 30+40 chars for key+TTL when the real content is narrower. +func computeLayout(columns []columnKind, out io.Writer, entries []Entry) columnLayout { + var lay columnLayout + termWidth := detectTerminalWidth(out) + + // Scan entries for actual max key/TTL content widths. + for _, e := range entries { + if w := utf8.RuneCountInString(e.Key); w > lay.key { + lay.key = w + } + if w := utf8.RuneCountInString(formatExpiry(e.ExpiresAt)); w > lay.ttl { + lay.ttl = w + } + } + if lay.key > keyColumnWidthCap { + lay.key = keyColumnWidthCap + } + if lay.ttl > ttlColumnWidthCap { + lay.ttl = ttlColumnWidthCap + } + + if termWidth <= 0 { + return lay + } + + padding := len(columns) * 2 + available := termWidth - padding + if available < len(columns) { + return lay + } + + // Give the value column whatever is left after key and TTL. + lay.value = available + for _, col := range columns { + switch col { + case columnKey: + lay.value -= lay.key + case columnTTL: + lay.value -= lay.ttl + } + } + if lay.value < 10 { + lay.value = 10 + } + return lay +} + +func applyColumnWidths(tw table.Writer, columns []columnKind, out io.Writer, lay columnLayout, full bool) { termWidth := detectTerminalWidth(out) if termWidth <= 0 { return } tw.SetAllowedRowLength(termWidth) - // Padding per column: go-pretty's default is one space each side. - padding := len(columns) * 2 - available := termWidth - padding - if available < len(columns) { - return - } - - // Give key and TTL columns a fixed budget; value gets the rest. - const keyWidth = 30 - const ttlWidth = 40 - valueWidth := available - for _, col := range columns { - switch col { - case columnKey: - valueWidth -= keyWidth - case columnTTL: - valueWidth -= ttlWidth - } - } - if valueWidth < 10 { - valueWidth = 10 - } - var configs []table.ColumnConfig for i, col := range columns { var maxW int + var enforcer func(string, int) string switch col { case columnKey: - maxW = keyWidth + maxW = lay.key + enforcer = text.Trim case columnValue: - maxW = valueWidth + maxW = lay.value + if full { + enforcer = text.WrapText + } + // When !full, values are already pre-truncated by + // summariseValue — no enforcer needed. case columnTTL: - maxW = ttlWidth + maxW = lay.ttl + enforcer = text.Trim } configs = append(configs, table.ColumnConfig{ Number: i + 1, WidthMax: maxW, - WidthMaxEnforcer: text.WrapText, + WidthMaxEnforcer: enforcer, }) } tw.SetColumnConfigs(configs) @@ -318,8 +459,9 @@ func init() { listCmd.Flags().BoolVarP(&listBase64, "base64", "b", false, "view binary data as base64") listCmd.Flags().BoolVar(&listNoKeys, "no-keys", false, "suppress the key column") listCmd.Flags().BoolVar(&listNoValues, "no-values", false, "suppress the value column") - listCmd.Flags().BoolVarP(&listTTL, "ttl", "t", false, "append a TTL column when entries expire") - listCmd.Flags().BoolVar(&listHeader, "header", false, "include header row") + listCmd.Flags().BoolVar(&listNoTTL, "no-ttl", false, "suppress the TTL column") + listCmd.Flags().BoolVarP(&listFull, "full", "f", false, "show full values without truncation") + listCmd.Flags().BoolVar(&listNoHeader, "no-header", false, "suppress the header row") listCmd.Flags().VarP(&listFormat, "format", "o", "output format (table|tsv|csv|markdown|html|ndjson)") listCmd.Flags().StringSliceP("glob", "g", nil, "Filter keys with glob pattern (repeatable)") listCmd.Flags().String("glob-sep", "", fmt.Sprintf("Characters treated as separators for globbing (default '%s')", defaultGlobSeparatorsDisplay())) diff --git a/cmd/shared.go b/cmd/shared.go index 76fb1cd..517162b 100644 --- a/cmd/shared.go +++ b/cmd/shared.go @@ -262,14 +262,14 @@ func validateDBName(name string) error { func formatExpiry(expiresAt uint64) string { if expiresAt == 0 { - return "never" + return "no expiry" } expiry := time.Unix(int64(expiresAt), 0).UTC() remaining := time.Until(expiry) if remaining <= 0 { - return fmt.Sprintf("%s (expired)", expiry.Format(time.RFC3339)) + return "expired" } - return fmt.Sprintf("%s (in %s)", expiry.Format(time.RFC3339), remaining.Round(time.Second)) + return fmt.Sprintf("in %s", remaining.Round(time.Second)) } // Keys returns all keys for the provided store name (or default if empty). diff --git a/testdata/help__list__ok.ct b/testdata/help__list__ok.ct index 2201bd3..5cbf7d2 100644 --- a/testdata/help__list__ok.ct +++ b/testdata/help__list__ok.ct @@ -11,13 +11,14 @@ Aliases: Flags: -b, --base64 view binary data as base64 -o, --format format output format (table|tsv|csv|markdown|html|ndjson) (default table) + -f, --full show full values without truncation -g, --glob strings Filter keys with glob pattern (repeatable) --glob-sep string Characters treated as separators for globbing (default '/-_.@: ') - --header include header row -h, --help help for list + --no-header suppress the header row --no-keys suppress the key column + --no-ttl suppress the TTL column --no-values suppress the value column - -t, --ttl append a TTL column when entries expire List the contents of a store Usage: @@ -29,10 +30,11 @@ Aliases: Flags: -b, --base64 view binary data as base64 -o, --format format output format (table|tsv|csv|markdown|html|ndjson) (default table) + -f, --full show full values without truncation -g, --glob strings Filter keys with glob pattern (repeatable) --glob-sep string Characters treated as separators for globbing (default '/-_.@: ') - --header include header row -h, --help help for list + --no-header suppress the header row --no-keys suppress the key column + --no-ttl suppress the TTL column --no-values suppress the value column - -t, --ttl append a TTL column when entries expire diff --git a/testdata/list__glob__ok.ct b/testdata/list__glob__ok.ct index ece552c..d6f3564 100644 --- a/testdata/list__glob__ok.ct +++ b/testdata/list__glob__ok.ct @@ -2,9 +2,11 @@ $ pda set a1@lg 1 $ pda set a2@lg 2 $ pda set b1@lg 3 $ pda ls lg --glob a* --format tsv -a1 1 -a2 2 +Key Value TTL +a1 1 no expiry +a2 2 no expiry $ pda ls lg --glob b* --format tsv -b1 3 +Key Value TTL +b1 3 no expiry $ pda ls lg --glob c* --> FAIL FAIL cannot ls '@lg': no matches for pattern 'c*' diff --git a/testdata/remove__dedupe__ok.ct b/testdata/remove__dedupe__ok.ct index 54c5ba8..ec7d330 100644 --- a/testdata/remove__dedupe__ok.ct +++ b/testdata/remove__dedupe__ok.ct @@ -1,15 +1,15 @@ $ pda set foo 1 $ pda set bar 2 $ pda ls - a echo hello - - a1 1 - a2 2 - b1 3 - bar 2 - copied-key hidden-value - foo 1 - moved-key hidden-value +KEY VALUE TTL +a echo hello (..1 more chars) no expiry +a1 1 no expiry +a2 2 no expiry +b1 3 no expiry +bar 2 no expiry +copied-key hidden-value no expiry +foo 1 no expiry +moved-key hidden-value no expiry $ pda rm foo --glob "*" $ pda get bar --> FAIL FAIL cannot get 'bar': no such key