Skip to content

File-backed query history, project-scoped config, edit-in-editor - #224

Closed
PeterAdams-ecitsolutions wants to merge 9 commits into
Maxteabag:mainfrom
PeterAdams-ecitsolutions:sql-file-history
Closed

PeterAdams-ecitsolutions wants to merge 9 commits into
Maxteabag:mainfrom
PeterAdams-ecitsolutions:sql-file-history

Conversation

@PeterAdams-ecitsolutions

Copy link
Copy Markdown
Contributor

Three related changes that build on each other:

  1. File-backed query history — query_history.json becomes per-query .sql files under <config>/queries/<connection>/<timestamp>_<hash>.sql with a small -- sqlit:history header. Re-running the same query touches the existing file instead of duplicating. Old JSON is migrated once on first launch.

  2. Project-scoped config — sqlit . or sqlit /path/to/repo routes connections, history, and starred queries into <project>/.sqlit/ so a team can commit shared db setup and useful queries to a repo. Theme, settings, and credentials stay global.

  3. space + E to edit query in terminal editor — leader-menu command that opens the current query in your $VISUAL/$EDITOR (or a picker on first use). Edited content replaces the buffer and saves to history regardless of execution.

Test coverage: 60+ new tests across tests/unit/ and tests/ui/ covering the store, the project-dir routing, editor detection, and the leader-command guard.

Maxteabag added 9 commits May 24, 2026 11:54
History moves from a single `query_history.json` to per-query `.sql`
files under `<config>/queries/<connection>/<timestamp>_<hash>.sql`,
each with a small `-- sqlit:history` header (connection, database,
timestamp). Files sort chronologically by name and grep cleanly.

Re-running an existing query (same content after `strip()`) deletes
the old file and writes a fresh one with the new timestamp, so dedup
remains content-based and the most-recent timestamp wins. The 100-
entry-per-connection cap is enforced by lex-order eviction.

Public API (`HistoryStore` methods) is unchanged so existing callers
in `query_execution.py` and `services.py` keep working. A one-time
migration on first construct fans out any legacy `query_history.json`
into the new tree and renames it to `.migrated`.
Passing a directory as the first positional arg routes connections,
query history, and starred queries into `<dir>/.sqlit/` instead of
the global config. Theme, settings, and credentials (keyring) stay
global. `sqlit .` resolves to the current shell directory; a missing
directory errors out cleanly with exit code 1.

Use case: a team can commit `<repo>/.sqlit/connections.json` and
useful saved queries to share project-specific db setup, while each
developer keeps their personal theme/keyring out of the repo.

`ConnectionStore` and `StarredStore` gain a `file_path` constructor
arg; `HistoryStore` already accepts `base_dir`. `RuntimeConfig` gains
`project_dir` plus a `project_config_dir` helper. `build_app_services`
threads these into the stores when `project_dir` is set.
When the query editor has focus, the leader menu picks up a new "Edit
in editor" entry. On first use (or when the saved preference is no
longer installed) a picker pops up listing detected terminal editors
(nvim, vim, helix, kakoune, micro, nano, emacs, vi). Missing ones are
grayed out. The selection is saved to settings as `preferred_editor`.

The action writes the current query buffer to a temp `.sql` file with
a meaningful name (`sqlit-edit-<timestamp>-XXXX.sql`), suspends the
TUI via `app.suspend()`, runs the editor blocking on that file, then
loads the result back into the buffer. When the connection is saved,
the edited text is also saved to history as a new entry — so edits
land in the file-backed history regardless of whether they were
executed. No-op edits short-circuit.
Capital `E` required Shift; `o` is unused in the global leader menu
(e/f/c/x/z/t/h/space/slash/q are taken). Action and behavior unchanged.
The action was silent in the empty-state cases. Add user-visible
notifications:
- No installed editor anywhere → error notification with install hint;
  the picker no longer opens (all options would be disabled anyway).
- Picker about to open → info notification explaining first-time setup.
- Picker cancelled → warning notification.
- Editor closed with no changes → info notification.
- Buffer updated from editor → info notification.
When the guard failed (focus ≠ "query"), `resolve_action` returned
None for the leader resolution, then `on_key` cancelled leader-pending
and retried the raw key. In query NORMAL mode that retry resolved
`o` to vim's `open_line_below` action, which silently inserted a
blank line — looking like "nothing happened" to the user.

The action handles an empty buffer fine, so the guard isn't needed.
Drop it so space+o always reaches the editor regardless of which
pane has focus.
The `-- database:` and `-- ran:` header lines were carrying data that
already had a natural home in the filesystem:

- `database` → subdirectory under the connection dir. Multi-database
  connections (Postgres, MSSQL, etc.) get one subdir per db; single-
  database engines (SQLite, DuckDB) keep files flat under the connection
  dir. Loading walks both levels.
- `ran:` timestamp → already in the filename prefix
  (`YYYY-MM-DDTHH-MM-SS_<hash>.sql`).

Header collapses to two lines:

    -- sqlit:history
    -- connection: postgres-prod

    SELECT * FROM users

Eviction still operates per-connection (summing files across db subdirs);
re-running an existing query continues to dedup by query hash even when
the database moves between executions.

Files written under the previous v1 layout are migrated in-place: each
file's `-- database:` header is read, the file moves into the
appropriate subdir, and the header is rewritten in the trimmed form.
Nobody has v1 files in the wild — that layout only existed in the
earlier commits of this PR. Removing the migration code and its tests.
The legacy JSON migration is unrelated and stays.
The previous logic deleted any same-hash file across every database
subdir on the connection, so running the same query against two
databases silently destroyed the older entry. With database as a
structural part of the path, the same query against different
databases is two legitimately distinct events — dedup should be
per (conn, db), matching the path layout.

The test that locked in the wrong behavior is replaced with one
asserting both entries are preserved, plus a complementary test that
re-running the same query against the same database still collapses
to a single file.
@Maxteabag

Copy link
Copy Markdown
Owner

Reopening as a Maxteabag-authored PR — see #225.

@Maxteabag Maxteabag closed this May 24, 2026
@Maxteabag

Copy link
Copy Markdown
Owner

Correction: reopened as #226.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants