Skip to content

Latest commit

 

History

History
90 lines (60 loc) · 7.92 KB

File metadata and controls

90 lines (60 loc) · 7.92 KB

lazyduckdb

A TUI for querying parquet files with DuckDB. Built with Bubble Tea — cmd/<app>/main.go entry, feature packages under internal/. In the results pane, press / to open a client-side incremental search: typing filters highlights live across every loaded row (case-insensitive), Enter cycles to the next match (wraps around), and Esc exits search — arrow keys keep scrolling while highlights stay on.

Keybindings: follow macOS conventions

This is primarily a macOS app. Keybindings should feel native. Always register both a Cmd form and a Ctrl form for every primary action — the Cmd form is what the user reaches for, the Ctrl form is the fallback for terminals that don't forward Cmd.

Action Bind
Run query (auto-focuses results) super+r, ctrl+r
Export to Excel super+e, ctrl+e
Toggle editor ↔ results esc (primary), ctrl+q (editor-only fallback)
Word left alt+left, alt+b
Word right alt+right, alt+f
Line start / end home/end (add ctrl+a/ctrl+e if needed)

Focus model is query-driven: running a query auto-focuses results, and esc is a symmetric toggle between the two panes (issue #1). The editor→results half of the toggle is gated on a result set being loaded — without one there's nothing to scroll, so esc is a no-op there. Don't re-introduce a manual super+t / ctrl+t focus-results binding — cmd+t is swallowed by every macOS terminal (New Tab), and the toggle covers the same need with one fewer key. The editor's own esc handler (dismiss completion list) takes priority when a completion is open.

Why two forms for each

Cmd as super+*: On macOS the Command key is reported as super by the Kitty keyboard protocol. This app targets Bubble Tea v2, which requests Kitty by default, so terminals that support it (Ghostty, Kitty, WezTerm, modern iTerm2 with reporting enabled) deliver super+r etc. to the app. Terminals that don't support Kitty (macOS Terminal.app, older iTerm2 configs) swallow the Cmd chord and it never arrives — that's when the ctrl fallback saves the user.

Option as both alt+<arrow> and alt+<letter>: macOS Terminal (default) and iTerm2's "Natural text editing" preset send Option+Arrow as ESC b/ESC f (readline backward-word/forward-word), which Bubble Tea surfaces as alt+b/alt+f. Users who've set "Left Option key = Esc+" or enabled CSI-u mode get alt+left/alt+right instead. Handling only one form breaks the app for half the userbase.

The rule

When you add a new shortcut, ask:

  • Is there a Cmd-equivalent a macOS user would reach for? Bind both super+* and ctrl+*.
  • Does it involve Option+Arrow? Bind both alt+<arrow> and the readline alt+<letter> alias.
  • Don't use cmd+c / super+c — it collides with Copy in every terminal. ctrl+c stays Quit.

Project layout

  • cmd/lazyduckdb/main.go — entrypoint, handles the CLI arg / picker branch
  • internal/duck — DuckDB session (opens parquet as view t, runs queries)
  • internal/editor — multi-line SQL editor with tab-complete
  • internal/table — horizontally scrollable results view
  • internal/export — xlsx export via excelize
  • internal/picker — parquet file selector (used when no CLI arg)
  • internal/app — Bubble Tea root model that wires everything together
  • internal/keymap — binding defaults, centralized so they're easy to audit

Running

go run ./cmd/lazyduckdb [parquet_file ...]

With no argument it lists *.parquet in the current directory and lets you pick one.

Multi-file CLI contract

There are three ways files end up mounted, and they're not interchangeable — preserve the distinction when touching cmd/lazyduckdb/main.go:

  1. Explicit list of unrelated files (lazyduckdb a.parquet b.parquet) → mounted as t1, t2, … via duck.Open + Session.Attach. Existing cross-file-join workflow.
  2. Shell-expanded glob (lazyduckdb Ford*.parquet, which the shell turns into Ford1.parquet Ford2.parquet … before lazyduckdb sees it) → folded into a single unioned t / t1 via duck.OpenGroup. The fold is gated by detectGlobExpansion in main.go: same directory, no already-glob arg, ≥ 2 chars of common basename prefix or suffix-body. Anything weaker falls back to (1) — that's intentional; don't loosen the threshold without tests covering the regressions in main_test.go.
  3. Quoted glob (lazyduckdb 'Ford*.parquet') → reaches the binary unexpanded; duck.IsGlob short-circuits the per-arg stat, and duck.Open hands the pattern to read_parquet for DuckDB-side expansion. Picker Ctrl+A lands here too via OpenGroup with the picker's reconstructed label.

When adding a new arg-handling code path, decide which of these three buckets it belongs to before writing — mixing them produces hard-to-debug "why are some queries hitting only one file" reports.

Shortcut: "install"

When the user's prompt is just the word install (case-insensitive, with or without trailing punctuation), run:

go install ./cmd/lazyduckdb

This drops a fresh lazyduckdb binary into $(go env GOPATH)/bin (~/go/bin on this machine, which is on the user's PATH). Confirm with which lazyduckdb && lazyduckdb -v and report the result. Don't ask for confirmation — install is the confirmation.

Shortcut: "release"

When the user's prompt is release or release <X.Y.Z> (case-insensitive, optional v prefix), cut a full release end-to-end: bump, commit, tag, push, and publish a GitHub Release. The word itself is the confirmation; don't ask again, but DO run the steps in order and stop on the first failure.

Pushing to master may trip the auto-mode classifier (the default branch normally requires PR review). If git push github master is denied, surface the block and wait for the user to type push — then re-run from the push step onward. Do not stop at "local-only" otherwise; the prior contract that left tags unpushed produced incomplete releases (commit + tag locally, no GitHub Release ever published).

If no version was given, infer the next one: read the latest tag with git tag --list 'v*' --sort=-v:refname | head -1, fall back to the version constant in cmd/lazyduckdb/main.go if there are no tags, and bump the patch component (v0.1v0.1.1, v0.2.3v0.2.4). If the user gave a version, use it verbatim — don't second-guess major/minor bumps.

Steps, in this order:

  1. Pre-flight: git status --porcelain must be empty and the current branch must be master. Refuse otherwise; tell the user what's dirty.
  2. Bump the version constant in cmd/lazyduckdb/main.go — the version var. Strip the leading v (the constant stores 0.2.0, the tag is v0.2.0). The update-check in internal/update compares against this string, so the two must stay in lockstep.
  3. Build and test: go build ./... && go test ./.... Stop on failure.
  4. Commit: stage only cmd/lazyduckdb/main.go, message release vX.Y.Z. Heredoc form with the standard Co-Authored-By trailer.
  5. Tag: git tag -a vX.Y.Z -m "vX.Y.Z" (annotated, not lightweight — gh release and go install @vX.Y.Z both prefer annotated).
  6. Push: git push github master && git push github vX.Y.Z. The remote is named github, not origin (see git remote -v). If the classifier denies the master push, stop and wait for the user's push confirmation, then continue.
  7. Publish the GitHub Release: gh release create vX.Y.Z --generate-notes --title "vX.Y.Z". Prior releases use auto-generated notes only (just the "Full Changelog" compare link); don't hand-write notes unless asked. Print the resulting URL.

Don't git tag -d or git reset to "undo" a release commit — if the user wants to abandon, they can drop the tag and reset themselves; the release flow leaves the working tree at a publishable state and that's the contract. Never use --force on a tag push, and don't amend the release commit after pushing — cut a new patch instead.