feat: default ttl and header visibility, and removed unnecessray padding from tab output

This commit is contained in:
Lewis Wynne 2026-02-11 14:14:02 +00:00
parent 07330be10b
commit 24853bfce8
6 changed files with 232 additions and 69 deletions

View file

@ -192,19 +192,34 @@ pda rm kitty -i
`pda ls` to see what you've got stored. `pda ls` to see what you've got stored.
```bash ```bash
pda ls pda ls
# name Alice # KEY VALUE TTL
# dogs four legged mammals # name Alice no expiry
# dogs four legged mammals no expiry
# Or as CSV. # Or as CSV.
pda ls --format csv pda ls --format csv
# name,Alice # Key,Value,TTL
# dogs,four legged mammals # name,Alice,no expiry
# dogs,four legged mammals,no expiry
# Or TSV, or Markdown, or HTML. # Or TSV, or Markdown, or HTML.
``` ```
<p align="center"></p><!-- spacer --> <p align="center"></p><!-- spacer -->
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
```
<p align="center"></p><!-- spacer -->
`pda export` to export everything as NDJSON. `pda export` to export everything as NDJSON.
```bash ```bash
pda export > my_backup pda export > my_backup
@ -536,11 +551,12 @@ pda set session2 "xyz" --ttl 54m10s
<p align="center"></p><!-- spacer --> <p align="center"></p><!-- spacer -->
`list --ttl` shows expiration date in list output. `list` shows expiration in the TTL column by default.
```bash ```bash
pda ls --ttl pda ls
# session 123 2025-11-21T15:30:00Z (in 59m30s) # KEY VALUE TTL
# session2 xyz 2025-11-21T15:21:40Z (in 51m40s) # 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. `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. 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 ```bash
pda ls pda ls
# api-key locked (identity file missing) # KEY VALUE TTL
# api-key locked (identity file missing) no expiry
pda get api-key pda get api-key
# FAIL cannot get 'api-key': secret is locked (identity file missing) # FAIL cannot get 'api-key': secret is locked (identity file missing)

View file

@ -29,6 +29,8 @@ import (
"io" "io"
"os" "os"
"strconv" "strconv"
"strings"
"unicode/utf8"
"filippo.io/age" "filippo.io/age"
"github.com/jedib0t/go-pretty/v6/table" "github.com/jedib0t/go-pretty/v6/table"
@ -58,9 +60,12 @@ var (
listBase64 bool listBase64 bool
listNoKeys bool listNoKeys bool
listNoValues bool listNoValues bool
listTTL bool listNoTTL bool
listHeader bool listFull bool
listNoHeader bool
listFormat formatEnum = "table" listFormat formatEnum = "table"
dimStyle = text.Colors{text.Faint, text.Italic}
) )
type columnKind int type columnKind int
@ -99,8 +104,8 @@ func list(cmd *cobra.Command, args []string) error {
targetDB = "@" + dbName targetDB = "@" + dbName
} }
if listNoKeys && listNoValues && !listTTL { if listNoKeys && listNoValues && listNoTTL {
return withHint(fmt.Errorf("cannot ls '%s': no columns selected", targetDB), "disable --no-keys/--no-values or pass --ttl") return withHint(fmt.Errorf("cannot ls '%s': no columns selected", targetDB), "disable --no-keys, --no-values, or --no-ttl")
} }
var columns []columnKind var columns []columnKind
@ -110,7 +115,7 @@ func list(cmd *cobra.Command, args []string) error {
if !listNoValues { if !listNoValues {
columns = append(columns, columnValue) columns = append(columns, columnValue)
} }
if listTTL { if !listNoTTL {
columns = append(columns, columnTTL) columns = append(columns, columnTTL)
} }
@ -183,39 +188,131 @@ func list(cmd *cobra.Command, args []string) error {
tw.Style().Options.DrawBorder = false tw.Style().Options.DrawBorder = false
tw.Style().Options.SeparateRows = false tw.Style().Options.SeparateRows = false
tw.Style().Options.SeparateColumns = 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)) tw.AppendHeader(headerRow(columns))
if tty {
tw.Style().Color.Header = text.Colors{text.Bold}
} }
}
lay := computeLayout(columns, output, filtered)
for _, e := range filtered { for _, e := range filtered {
var valueStr string var valueStr string
dimValue := false
if showValues { if showValues {
if e.Locked { if e.Locked {
valueStr = "locked (identity file missing)" valueStr = "locked (identity file missing)"
dimValue = true
} else { } else {
valueStr = store.FormatBytes(listBase64, e.Value) 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)) row := make(table.Row, 0, len(columns))
for _, col := range columns { for _, col := range columns {
switch col { switch col {
case columnKey: case columnKey:
if tty {
row = append(row, text.Bold.Sprint(e.Key))
} else {
row = append(row, e.Key) row = append(row, e.Key)
}
case columnValue: case columnValue:
if tty && dimValue {
row = append(row, dimStyle.Sprint(valueStr))
} else {
row = append(row, valueStr) row = append(row, valueStr)
}
case columnTTL: 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) tw.AppendRow(row)
} }
applyColumnWidths(tw, columns, output) applyColumnWidths(tw, columns, output, lay, listFull)
renderTable(tw) renderTable(tw)
return nil 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 { func headerRow(columns []columnKind) table.Row {
row := make(table.Row, 0, len(columns)) row := make(table.Row, 0, len(columns))
for _, col := range columns { for _, col := range columns {
@ -231,51 +328,95 @@ func headerRow(columns []columnKind) table.Row {
return 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) termWidth := detectTerminalWidth(out)
if termWidth <= 0 { if termWidth <= 0 {
return return
} }
tw.SetAllowedRowLength(termWidth) 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 var configs []table.ColumnConfig
for i, col := range columns { for i, col := range columns {
var maxW int var maxW int
var enforcer func(string, int) string
switch col { switch col {
case columnKey: case columnKey:
maxW = keyWidth maxW = lay.key
enforcer = text.Trim
case columnValue: 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: case columnTTL:
maxW = ttlWidth maxW = lay.ttl
enforcer = text.Trim
} }
configs = append(configs, table.ColumnConfig{ configs = append(configs, table.ColumnConfig{
Number: i + 1, Number: i + 1,
WidthMax: maxW, WidthMax: maxW,
WidthMaxEnforcer: text.WrapText, WidthMaxEnforcer: enforcer,
}) })
} }
tw.SetColumnConfigs(configs) tw.SetColumnConfigs(configs)
@ -318,8 +459,9 @@ func init() {
listCmd.Flags().BoolVarP(&listBase64, "base64", "b", false, "view binary data as base64") 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(&listNoKeys, "no-keys", false, "suppress the key column")
listCmd.Flags().BoolVar(&listNoValues, "no-values", false, "suppress the value 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(&listNoTTL, "no-ttl", false, "suppress the TTL column")
listCmd.Flags().BoolVar(&listHeader, "header", false, "include header row") 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().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().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())) listCmd.Flags().String("glob-sep", "", fmt.Sprintf("Characters treated as separators for globbing (default '%s')", defaultGlobSeparatorsDisplay()))

View file

@ -262,14 +262,14 @@ func validateDBName(name string) error {
func formatExpiry(expiresAt uint64) string { func formatExpiry(expiresAt uint64) string {
if expiresAt == 0 { if expiresAt == 0 {
return "never" return "no expiry"
} }
expiry := time.Unix(int64(expiresAt), 0).UTC() expiry := time.Unix(int64(expiresAt), 0).UTC()
remaining := time.Until(expiry) remaining := time.Until(expiry)
if remaining <= 0 { 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). // Keys returns all keys for the provided store name (or default if empty).

View file

@ -11,13 +11,14 @@ Aliases:
Flags: Flags:
-b, --base64 view binary data as base64 -b, --base64 view binary data as base64
-o, --format format output format (table|tsv|csv|markdown|html|ndjson) (default table) -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) -g, --glob strings Filter keys with glob pattern (repeatable)
--glob-sep string Characters treated as separators for globbing (default '/-_.@: ') --glob-sep string Characters treated as separators for globbing (default '/-_.@: ')
--header include header row
-h, --help help for list -h, --help help for list
--no-header suppress the header row
--no-keys suppress the key column --no-keys suppress the key column
--no-ttl suppress the TTL column
--no-values suppress the value column --no-values suppress the value column
-t, --ttl append a TTL column when entries expire
List the contents of a store List the contents of a store
Usage: Usage:
@ -29,10 +30,11 @@ Aliases:
Flags: Flags:
-b, --base64 view binary data as base64 -b, --base64 view binary data as base64
-o, --format format output format (table|tsv|csv|markdown|html|ndjson) (default table) -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) -g, --glob strings Filter keys with glob pattern (repeatable)
--glob-sep string Characters treated as separators for globbing (default '/-_.@: ') --glob-sep string Characters treated as separators for globbing (default '/-_.@: ')
--header include header row
-h, --help help for list -h, --help help for list
--no-header suppress the header row
--no-keys suppress the key column --no-keys suppress the key column
--no-ttl suppress the TTL column
--no-values suppress the value column --no-values suppress the value column
-t, --ttl append a TTL column when entries expire

View file

@ -2,9 +2,11 @@ $ pda set a1@lg 1
$ pda set a2@lg 2 $ pda set a2@lg 2
$ pda set b1@lg 3 $ pda set b1@lg 3
$ pda ls lg --glob a* --format tsv $ pda ls lg --glob a* --format tsv
a1 1 Key Value TTL
a2 2 a1 1 no expiry
a2 2 no expiry
$ pda ls lg --glob b* --format tsv $ pda ls lg --glob b* --format tsv
b1 3 Key Value TTL
b1 3 no expiry
$ pda ls lg --glob c* --> FAIL $ pda ls lg --glob c* --> FAIL
FAIL cannot ls '@lg': no matches for pattern 'c*' FAIL cannot ls '@lg': no matches for pattern 'c*'

View file

@ -1,15 +1,15 @@
$ pda set foo 1 $ pda set foo 1
$ pda set bar 2 $ pda set bar 2
$ pda ls $ pda ls
a echo hello KEY VALUE TTL
a echo hello (..1 more chars) no expiry
a1 1 a1 1 no expiry
a2 2 a2 2 no expiry
b1 3 b1 3 no expiry
bar 2 bar 2 no expiry
copied-key hidden-value copied-key hidden-value no expiry
foo 1 foo 1 no expiry
moved-key hidden-value moved-key hidden-value no expiry
$ pda rm foo --glob "*" $ pda rm foo --glob "*"
$ pda get bar --> FAIL $ pda get bar --> FAIL
FAIL cannot get 'bar': no such key FAIL cannot get 'bar': no such key