Skip to content

Commit b3b9ce6

Browse files
authored
feat(fuzzy-finder): add UX defaults and CLI_FUZZY_FINDER_OPTIONS passthrough (#534)
Add sensible fzf UX defaults and CLI_FUZZY_FINDER_OPTIONS system variable. Default fzf options (--select-1, --exit-0, --highlight-line, --cycle) are set as CLI args in prepareFzfOptions so they can be overridden by users. CLI_FUZZY_FINDER_OPTIONS allows arbitrary fzf flag passthrough with last-wins semantics. Uses google/shlex for proper shell-style word splitting, supporting quoted arguments like --header='My Header'. Documents --tmux incompatibility (opts.Input Go channel vs external process). Refines CLAUDE.md no-future-proofing guideline and adds merge-over-rebase policy. Fixes #526
1 parent d2fb6bd commit b3b9ce6

9 files changed

Lines changed: 105 additions & 11 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,13 +142,14 @@ For testing best practices (table-driven tests, coverage comparison, test organi
142142
### Git Practices
143143
- **CRITICAL**: Always use `git add <specific-files>` (never `git add .` or `git add -A`)
144144
- **CRITICAL**: Never rewrite pushed commits without explicit permission
145+
- **Conflict resolution**: Use `git merge origin/main` (not rebase). This repo uses squash merge, so commit history cleanliness doesn't matter — merge is safer and preserves context.
145146
- Link PRs to issues: "Fixes #issue-number"
146147
- Check `git status` before committing
147148

148149
## Important Notes
149150

150151
- **No backward compatibility required** - not used as an external library
151-
- **No future-proofing** - only implement what's needed now
152+
- **No future-proofing, but choose correct solutions** - don't build speculative features (plugin systems, unnecessary abstractions), but when a clearly correct solution exists at similar cost, prefer it over a known-incomplete alternative. When in doubt, present options to the user rather than automatically choosing the minimal path.
152153
- **Issue management**: All fixes go through PRs - never close issues manually
153154
- **License headers**: New files use "Copyright [year] apstndb"; files from spanner-cli keep "Copyright [year] Google LLC"
154155

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -947,6 +947,7 @@ They have almost same semantics with [Spanner JDBC properties](https://cloud.goo
947947
| CLI_STREAMING | READ_WRITE | `"AUTO"` |
948948
| CLI_TABLE_PREVIEW_ROWS | READ_WRITE | `50` |
949949
| CLI_FUZZY_FINDER_KEY | READ_WRITE | `"C_T"` |
950+
| CLI_FUZZY_FINDER_OPTIONS | READ_WRITE | `""` |
950951

951952
> **Note**: `CLI_FORMAT` accepts the following values:
952953
> - `TABLE` - ASCII table with borders (default for both interactive and batch modes)
@@ -967,6 +968,10 @@ They have almost same semantics with [Spanner JDBC properties](https://cloud.goo
967968
>
968969
> For Table formats with streaming enabled, `CLI_TABLE_PREVIEW_ROWS` (default: 50) controls how many rows are used to calculate column widths before streaming the rest.
969970
971+
> **Note**: `CLI_FUZZY_FINDER_OPTIONS` passes additional fzf options to the fuzzy finder. Options are appended after built-in defaults, so user options take precedence (last wins).
972+
> Built-in defaults: `--reverse`, `--no-sort`, `--select-1`, `--exit-0`, `--highlight-line`, `--cycle`, `--border=rounded`, `--info=inline-right`.
973+
> `--tmux` is not supported because the fuzzy finder runs fzf in-process via the Go library.
974+
970975
### Batch statements
971976

972977
#### DDL batching

docs/system_variables.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,4 +141,24 @@ TODO
141141
- Batching can improve performance when importing large datasets
142142
- The last batch may contain fewer rows than the batch size
143143

144+
#### Interactive / Fuzzy Finder Variables
145+
146+
##### CLI_FUZZY_FINDER_OPTIONS
147+
- **Type**: STRING
148+
- **Default**: (empty)
149+
- **Description**: Additional fzf options passed to the fuzzy finder
150+
- **Access**: Read/Write
151+
- **Usage**:
152+
```sql
153+
SET CLI_FUZZY_FINDER_OPTIONS = '--color=dark';
154+
SET CLI_FUZZY_FINDER_OPTIONS = '--no-select-1 --no-cycle'; -- Override defaults
155+
SET CLI_FUZZY_FINDER_OPTIONS = ''; -- Reset to defaults only
156+
```
157+
- **Notes**:
158+
- Options are appended after built-in defaults, so user options take precedence (last wins)
159+
- Uses standard fzf option syntax (space-separated flags)
160+
- Built-in defaults: `--reverse`, `--no-sort`, `--height`, `--border=rounded`, `--info=inline-right`, `--select-1`, `--exit-0`, `--highlight-line`, `--cycle`
161+
- Useful for customizing appearance (colors, layout) or behavior (sorting, preview)
162+
- `--tmux` is **not supported** because the fuzzy finder runs fzf in-process via the Go library
163+
144164
TODO: Document other CLI_* variables

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ require (
109109
github.com/golang/snappy v1.0.0 // indirect
110110
github.com/google/jsonschema-go v0.2.0 // indirect
111111
github.com/google/s2a-go v0.1.9 // indirect
112+
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
112113
github.com/google/uuid v1.6.0 // indirect
113114
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
114115
github.com/googleapis/gax-go/v2 v2.15.0 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2791,6 +2791,8 @@ github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8
27912791
github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
27922792
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
27932793
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
2794+
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
2795+
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
27942796
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
27952797
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
27962798
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=

internal/mycli/fuzzy_finder.go

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import (
3131
"cloud.google.com/go/spanner/admin/database/apiv1/databasepb"
3232
"github.com/apstndb/lox"
3333
"github.com/cloudspannerecosystem/memefish/ast"
34+
"github.com/google/shlex"
3435
"github.com/hymkor/go-multiline-ny"
3536
fzf "github.com/junegunn/fzf/src"
3637
"github.com/nyaosorg/go-readline-ny"
@@ -130,7 +131,7 @@ func (f *fuzzyFinderCommand) Call(ctx context.Context, B *readline.Buffer) readl
130131
// Terminal handoff: move cursor below editor, run fzf, then restore
131132
rewind := f.editor.GotoEndLine()
132133

133-
chosen, ok := runFzf(candidates, result.argPrefix, completionHeader(result.completionType))
134+
chosen, ok := runFzf(candidates, result.argPrefix, completionHeader(result.completionType), f.cli.SystemVariables.FuzzyFinderOptions)
134135

135136
rewind()
136137
B.RepaintLastLine()
@@ -311,6 +312,8 @@ func prepareFzfOptions(candidates []fzfItem, header string) fzfPrepared {
311312
"--height=" + height,
312313
"--border=rounded",
313314
"--info=inline-right",
315+
"--select-1", "--exit-0",
316+
"--highlight-line", "--cycle",
314317
}
315318
if hasLabels {
316319
fzfArgs = append(fzfArgs, "--delimiter="+fzfDelimiter, "--with-nth=2..")
@@ -355,11 +358,23 @@ func extractValue(line string, hasLabels bool) string {
355358
// runFzf runs the fzf fuzzy finder with the given candidates and optional initial query.
356359
// When candidates have separate Label and Value, fzf displays/searches the Label
357360
// but the returned string is the Value (for insertion into the buffer).
361+
// extraOptions is an optional string of additional fzf flags (from CLI_FUZZY_FINDER_OPTIONS)
362+
// that are appended after built-in defaults so user options take precedence.
358363
// Returns the selected Value and true, or ("", false) if cancelled or no match.
359-
func runFzf(candidates []fzfItem, query string, header string) (string, bool) {
364+
func runFzf(candidates []fzfItem, query string, header string, extraOptions string) (string, bool) {
360365
prepared := prepareFzfOptions(candidates, header)
361366

362-
opts, err := fzf.ParseOptions(false, prepared.args)
367+
args := prepared.args
368+
if extraOptions != "" {
369+
extra, err := shlex.Split(extraOptions)
370+
if err != nil {
371+
slog.Debug("fuzzy finder: parse extra options", "err", err)
372+
return "", false
373+
}
374+
args = append(args, extra...)
375+
}
376+
377+
opts, err := fzf.ParseOptions(false, args)
363378
if err != nil {
364379
slog.Debug("fuzzy finder: parse fzf options", "err", err)
365380
return "", false
@@ -372,6 +387,10 @@ func runFzf(candidates []fzfItem, query string, header string) (string, bool) {
372387
}
373388
opts.ForceTtyIn = true
374389

390+
// Feed candidates via in-process Go channel.
391+
// NOTE: This is incompatible with --tmux, which spawns an external fzf process
392+
// that cannot read from a Go channel. With --tmux, the child process receives
393+
// no input and exits immediately (especially with --exit-0).
375394
inputChan := make(chan string, len(prepared.formattedLines))
376395
for _, line := range prepared.formattedLines {
377396
inputChan <- line
@@ -401,18 +420,31 @@ func runFzf(candidates []fzfItem, query string, header string) (string, bool) {
401420
// runFzfFilter runs fzf in non-interactive --filter mode for testing.
402421
// It returns all matching Values (after Value/Label extraction).
403422
// This tests the full pipeline: formatting → fzf matching → result extraction.
404-
func runFzfFilter(candidates []fzfItem, filter string, header string) []string {
423+
func runFzfFilter(candidates []fzfItem, filter string, header string, extraOptions string) []string {
405424
prepared := prepareFzfOptions(candidates, header)
406425

426+
args := prepared.args
427+
if extraOptions != "" {
428+
extra, err := shlex.Split(extraOptions)
429+
if err != nil {
430+
return nil
431+
}
432+
args = append(args, extra...)
433+
}
434+
407435
// For filter mode, strip visual-only args and add --filter.
408-
filterArgs := make([]string, 0, len(prepared.args)+1)
409-
for _, arg := range prepared.args {
436+
filterArgs := make([]string, 0, len(args)+1)
437+
for _, arg := range args {
410438
// Skip args that don't apply in non-interactive filter mode.
411439
if strings.HasPrefix(arg, "--height") ||
412440
strings.HasPrefix(arg, "--border") ||
413441
strings.HasPrefix(arg, "--reverse") ||
414442
strings.HasPrefix(arg, "--info") ||
415-
strings.HasPrefix(arg, "--gap") {
443+
strings.HasPrefix(arg, "--gap") ||
444+
strings.HasPrefix(arg, "--select-1") || strings.HasPrefix(arg, "--no-select-1") ||
445+
strings.HasPrefix(arg, "--exit-0") || strings.HasPrefix(arg, "--no-exit-0") ||
446+
strings.HasPrefix(arg, "--highlight-line") || strings.HasPrefix(arg, "--no-highlight-line") ||
447+
strings.HasPrefix(arg, "--cycle") || strings.HasPrefix(arg, "--no-cycle") {
416448
continue
417449
}
418450
filterArgs = append(filterArgs, arg)

internal/mycli/fuzzy_finder_test.go

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -907,7 +907,7 @@ func TestRunFzfFilter_SimpleMatch(t *testing.T) {
907907
{Value: "alphabet"},
908908
}
909909

910-
results := runFzfFilter(candidates, "alph", "")
910+
results := runFzfFilter(candidates, "alph", "", "")
911911
assert.Contains(t, results, "alpha")
912912
assert.Contains(t, results, "alphabet")
913913
assert.NotContains(t, results, "beta")
@@ -922,7 +922,7 @@ func TestRunFzfFilter_LabelValueSeparation(t *testing.T) {
922922
}
923923

924924
// Filter by label text, but results should be Values.
925-
results := runFzfFilter(candidates, "CREATE", "Operations")
925+
results := runFzfFilter(candidates, "CREATE", "Operations", "")
926926
assert.Contains(t, results, "op-123")
927927
assert.NotContains(t, results, "op-456")
928928
assert.NotContains(t, results, "op-789")
@@ -932,10 +932,40 @@ func TestRunFzfFilter_StatementNames(t *testing.T) {
932932
items := buildStatementNameItems()
933933

934934
// Filter for "SHOW DATABASES" — should match the statement and return its insert text.
935-
results := runFzfFilter(items, "SHOW DATABASES", "Statements")
935+
results := runFzfFilter(items, "SHOW DATABASES", "Statements", "")
936936
assert.Contains(t, results, "SHOW DATABASES", "should find the no-arg statement")
937937
}
938938

939+
func TestRunFzfFilter_ExtraOptions(t *testing.T) {
940+
candidates := []fzfItem{
941+
{Value: "alpha"},
942+
{Value: "beta"},
943+
{Value: "gamma"},
944+
{Value: "alphabet"},
945+
}
946+
947+
// Passing extra options should not break filtering.
948+
results := runFzfFilter(candidates, "alph", "", "--no-sort")
949+
assert.Contains(t, results, "alpha")
950+
assert.Contains(t, results, "alphabet")
951+
assert.NotContains(t, results, "beta")
952+
assert.NotContains(t, results, "gamma")
953+
954+
// Empty extra options should work identically to no options.
955+
results = runFzfFilter(candidates, "beta", "", "")
956+
assert.Contains(t, results, "beta")
957+
assert.NotContains(t, results, "alpha")
958+
959+
// Quoted options should be parsed correctly (shlex).
960+
results = runFzfFilter(candidates, "alph", "", "--no-sort --header='test header'")
961+
assert.Contains(t, results, "alpha")
962+
assert.Contains(t, results, "alphabet")
963+
964+
// Malformed quotes should return nil (shlex parse error).
965+
results = runFzfFilter(candidates, "alph", "", "--header='unclosed")
966+
assert.Nil(t, results)
967+
}
968+
939969
func TestExtractValue(t *testing.T) {
940970
tests := []struct {
941971
name string

internal/mycli/system_variables.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ type systemVariables struct {
151151
SkipSystemCommand bool // CLI_SKIP_SYSTEM_COMMAND
152152
SkipColumnNames bool // CLI_SKIP_COLUMN_NAMES
153153
FuzzyFinderKey string // CLI_FUZZY_FINDER_KEY (empty = disabled)
154+
FuzzyFinderOptions string // CLI_FUZZY_FINDER_OPTIONS
154155

155156
// Streaming output configuration
156157
StreamingMode enums.StreamingMode // CLI_STREAMING

internal/mycli/var_registry.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,8 @@ func (r *VarRegistry) registerAll() {
214214
"A boolean indicating whether to suppress column headers in output. The default is false."))
215215
r.Register("CLI_FUZZY_FINDER_KEY", StringVar(&sv.FuzzyFinderKey,
216216
"Key binding for fuzzy finder. Uses go-readline-ny key names (e.g., C_T, M_F, F1). Set to empty string to disable. The default is C_T (Ctrl+T)."))
217+
r.Register("CLI_FUZZY_FINDER_OPTIONS", StringVar(&sv.FuzzyFinderOptions,
218+
"Additional fzf options passed to the fuzzy finder. Appended after built-in defaults, so user options take precedence. Example: --color=dark --no-select-1"))
217219
r.Register("CLI_STREAMING", StreamingModeVar(&sv.StreamingMode,
218220
"Controls streaming output mode: AUTO (format-dependent), TRUE (always stream), FALSE (never stream). Default is AUTO."))
219221

0 commit comments

Comments
 (0)