Skip to content
82 changes: 78 additions & 4 deletions sqlit/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,64 @@ def _get_schema_value_flags() -> set[str]:
return flags


def _looks_like_project_path(arg: str) -> bool:
"""Conservative test for a positional project-dir argument.

Only matches things that are obviously paths: `.`, `..`, anything
starting with `./`, `../`, `/`, `~`, or anything ending in `/`.
"""
if arg in {".", ".."}:
return True
if arg.startswith(("./", "../", "/", "~")):
return True
if arg.endswith("/"):
return True
return False


def _extract_project_dir(argv: list[str]) -> tuple[Path | None, list[str]]:
"""Extract a positional project-dir argument from argv if present.

Returns (project_dir, remaining_argv). Exits with an error if the
user passed a path that doesn't resolve to an existing directory.
"""
subcommands = {"connections", "connection", "connect", "query", "docker"}
result_argv: list[str] = []
project_dir: Path | None = None

i = 0
while i < len(argv):
arg = argv[i]
if i == 0:
result_argv.append(arg)
i += 1
continue
# Flags pass straight through (let argparse handle them).
if arg.startswith("-"):
result_argv.append(arg)
i += 1
continue
# First subcommand: copy the rest verbatim.
if arg in subcommands:
result_argv.extend(argv[i:])
break
if project_dir is None and _looks_like_project_path(arg):
resolved = Path(arg).expanduser().resolve()
if not resolved.is_dir():
print(
f"Error: project directory does not exist: {arg}",
file=sys.stderr,
)
sys.exit(1)
project_dir = resolved
i += 1
continue
result_argv.append(arg)
i += 1

return project_dir, result_argv


def _extract_connection_url(argv: list[str]) -> tuple[str | None, list[str]]:
"""Extract a connection URL from argv if present.

Expand Down Expand Up @@ -255,7 +313,12 @@ def _exec_restart(argv: list[str]) -> None:
os.execvp(exe, argv)


def _build_runtime(args: argparse.Namespace, startup_mark: float) -> RuntimeConfig:
def _build_runtime(
args: argparse.Namespace,
startup_mark: float,
*,
project_dir: Path | None = None,
) -> RuntimeConfig:
settings_path = Path(args.settings).expanduser() if args.settings else None
max_rows = args.max_rows if args.max_rows and args.max_rows > 0 else None
mock_install = args.mock_install if args.mock_install != "real" else None
Expand Down Expand Up @@ -297,6 +360,7 @@ def _build_runtime(args: argparse.Namespace, startup_mark: float) -> RuntimeConf

return RuntimeConfig(
settings_path=settings_path,
project_dir=project_dir,
theme=getattr(args, "theme", None),
max_rows=max_rows,
debug_mode=bool(args.debug),
Expand Down Expand Up @@ -328,15 +392,25 @@ def main() -> int:
)
log_startup_step("cli_start")

# Extract positional project-dir before argparse so we can route
# connections/history/starred into <project>/.sqlit/.
with startup_span("cli_extract_project_dir"):
project_dir, filtered_argv = _extract_project_dir(sys.argv)
# Extract connection URL before argparse (URLs conflict with subcommands)
with startup_span("cli_extract_connection_url"):
connection_url, filtered_argv = _extract_connection_url(sys.argv)
connection_url, filtered_argv = _extract_connection_url(filtered_argv)

log_startup_step("cli_parser_start")
parser = argparse.ArgumentParser(
prog="sqlit",
description="A terminal UI for SQL databases",
epilog="Connect via URL: sqlit mysql://user:pass@host/db, sqlit sqlite:///path/to/db.sqlite",
epilog=(
"Connect via URL: sqlit mysql://user:pass@host/db, "
"sqlit sqlite:///path/to/db.sqlite\n"
"Project mode: sqlit . or sqlit /path/to/project — "
"connections, history, and starred queries live in "
"<project>/.sqlit/ instead of the global config."
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)

Expand Down Expand Up @@ -581,7 +655,7 @@ def main() -> int:
log_startup_step("cli_parse_end")

with startup_span("runtime_build"):
runtime = _build_runtime(args, startup_mark)
runtime = _build_runtime(args, startup_mark, project_dir=project_dir)

with startup_span("services_build"):
services = build_app_services(runtime)
Expand Down
6 changes: 6 additions & 0 deletions sqlit/core/keymap.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,12 @@ def _build_leader_commands(self) -> list[LeaderCommandDef]:
# Actions
LeaderCommandDef("z", "cancel_operation", "Cancel", "Actions", guard="query_executing"),
LeaderCommandDef("t", "change_theme", "Change Theme", "Actions"),
LeaderCommandDef(
"o",
"edit_query_in_editor",
"Open in editor",
"Actions",
),
LeaderCommandDef("h", "show_help", "Help", "Actions"),
LeaderCommandDef("space", "telescope", "Telescope", "Actions"),
LeaderCommandDef("slash", "telescope_filter", "Telescope Search", "Actions"),
Expand Down
1 change: 1 addition & 0 deletions sqlit/core/leader_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
LEADER_GUARDS: dict[str, Callable[[InputContext], bool]] = {
"has_connection": lambda ctx: ctx.has_connection,
"query_executing": lambda ctx: ctx.query_executing,
"query_focused": lambda ctx: ctx.focus == "query",
}


Expand Down
11 changes: 9 additions & 2 deletions sqlit/domains/connections/store/connections.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
from sqlit.shared.core.store import CONFIG_DIR, JSONFileStore

if TYPE_CHECKING:
from pathlib import Path

from sqlit.domains.connections.app.credentials import CredentialsService
from sqlit.domains.connections.domain.config import ConnectionConfig

Expand All @@ -26,8 +28,13 @@ class ConnectionStore(JSONFileStore):
_instance: ConnectionStore | None = None
is_persistent: bool = True

def __init__(self, credentials_service: CredentialsService | None = None) -> None:
super().__init__(CONFIG_DIR / "connections.json")
def __init__(
self,
credentials_service: CredentialsService | None = None,
*,
file_path: Path | None = None,
) -> None:
super().__init__(file_path if file_path is not None else CONFIG_DIR / "connections.json")
self._credentials_service = credentials_service

@property
Expand Down
72 changes: 72 additions & 0 deletions sqlit/domains/query/app/editor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Detect terminal editors and resolve the user's editor preference."""

from __future__ import annotations

import os
import shutil
from dataclasses import dataclass


# Terminal editors we know about, ordered by typical preference.
# The order is what surfaces in the picker top-down.
_KNOWN_EDITORS: tuple[tuple[str, str], ...] = (
("nvim", "Neovim"),
("vim", "Vim"),
("hx", "Helix"),
("kak", "Kakoune"),
("micro", "Micro"),
("nano", "Nano"),
("emacs", "Emacs"),
("vi", "Vi"),
)


@dataclass(frozen=True)
class EditorEntry:
"""A terminal editor candidate."""

command: str
display_name: str
path: str | None # None when the binary isn't on PATH.

@property
def is_installed(self) -> bool:
return self.path is not None


def detect_editors() -> list[EditorEntry]:
"""Return all known editors, with each entry's resolved path or None."""
return [
EditorEntry(command=cmd, display_name=name, path=shutil.which(cmd))
for cmd, name in _KNOWN_EDITORS
]


def env_default_editor() -> str | None:
"""Return the first usable editor command from $VISUAL or $EDITOR."""
for var in ("VISUAL", "EDITOR"):
value = os.environ.get(var, "").strip()
if not value:
continue
# $EDITOR can include args (e.g. "code -w"). Take the binary.
cmd = value.split()[0]
if shutil.which(cmd):
return cmd
return None


def resolve_editor(preferred: str | None) -> str | None:
"""Pick the editor to use: preferred (if installed) else $VISUAL/$EDITOR."""
if preferred and shutil.which(preferred):
return preferred
return env_default_editor()


def build_editor_argv(editor_cmd: str) -> list[str]:
"""Build the argv prefix for invoking the editor on a file.

`emacs` is special-cased to use `-nw` so it stays in the terminal.
"""
if editor_cmd == "emacs":
return ["emacs", "-nw"]
return [editor_cmd]
4 changes: 4 additions & 0 deletions sqlit/domains/query/state/query_normal.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ def _setup_actions(self) -> None:
self.allows("g_leader_key", label="Go", help="Go motions (menu)")
self.allows("new_query", label="New", help="New query (clear all)")
self.allows("show_history", label="History", help="Query history")
self.allows(
"edit_query_in_editor",
help="Open current query in your terminal editor",
)
# Vim cursor movement
self.allows("cursor_left", help="Move cursor left")
self.allows("cursor_right", help="Move cursor right")
Expand Down
Loading
Loading