sqlide 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. sqlide/__init__.py +3 -0
  2. sqlide/__main__.py +5 -0
  3. sqlide/app.py +46 -0
  4. sqlide/cli.py +64 -0
  5. sqlide/clipboard.py +65 -0
  6. sqlide/config/__init__.py +1 -0
  7. sqlide/config/_toml.py +41 -0
  8. sqlide/config/connections.py +76 -0
  9. sqlide/config/keymap.py +25 -0
  10. sqlide/config/paths.py +48 -0
  11. sqlide/config/secrets.py +82 -0
  12. sqlide/config/settings.py +32 -0
  13. sqlide/consoles.py +100 -0
  14. sqlide/db/__init__.py +1 -0
  15. sqlide/db/completion.py +127 -0
  16. sqlide/db/factory.py +37 -0
  17. sqlide/db/metadata.py +174 -0
  18. sqlide/db/result.py +52 -0
  19. sqlide/db/session.py +339 -0
  20. sqlide/db/types.py +84 -0
  21. sqlide/doctor.py +60 -0
  22. sqlide/drivers/__init__.py +1 -0
  23. sqlide/drivers/catalog.toml +103 -0
  24. sqlide/drivers/cli.py +74 -0
  25. sqlide/drivers/custom.py +69 -0
  26. sqlide/drivers/loader.py +75 -0
  27. sqlide/drivers/maven.py +108 -0
  28. sqlide/drivers/registry.py +117 -0
  29. sqlide/export/__init__.py +12 -0
  30. sqlide/export/base.py +86 -0
  31. sqlide/export/csv_.py +40 -0
  32. sqlide/export/html.py +44 -0
  33. sqlide/export/json_.py +47 -0
  34. sqlide/export/markdown.py +27 -0
  35. sqlide/export/service.py +34 -0
  36. sqlide/export/sql_insert.py +37 -0
  37. sqlide/export/xlsx.py +109 -0
  38. sqlide/grid/__init__.py +1 -0
  39. sqlide/grid/copyfmt.py +97 -0
  40. sqlide/grid/formatting.py +43 -0
  41. sqlide/grid/model.py +109 -0
  42. sqlide/history/__init__.py +5 -0
  43. sqlide/history/store.py +117 -0
  44. sqlide/jvm/__init__.py +1 -0
  45. sqlide/jvm/locate.py +98 -0
  46. sqlide/jvm/runtime.py +44 -0
  47. sqlide/sql/__init__.py +1 -0
  48. sqlide/sql/context.py +195 -0
  49. sqlide/sql/dialects.py +93 -0
  50. sqlide/sql/format.py +50 -0
  51. sqlide/sql/keywords.py +143 -0
  52. sqlide/sql/lexer.py +148 -0
  53. sqlide/sql/snippets.py +44 -0
  54. sqlide/sql/splitter.py +325 -0
  55. sqlide/ui/__init__.py +1 -0
  56. sqlide/ui/app.tcss +67 -0
  57. sqlide/ui/commands.py +59 -0
  58. sqlide/ui/keymap.py +32 -0
  59. sqlide/ui/screens/__init__.py +0 -0
  60. sqlide/ui/screens/connection_editor.py +113 -0
  61. sqlide/ui/screens/dialogs.py +110 -0
  62. sqlide/ui/screens/driver_manager.py +195 -0
  63. sqlide/ui/screens/export_dialog.py +158 -0
  64. sqlide/ui/screens/grid_dialogs.py +92 -0
  65. sqlide/ui/screens/history.py +130 -0
  66. sqlide/ui/screens/main.py +383 -0
  67. sqlide/ui/screens/settings.py +77 -0
  68. sqlide/ui/widgets/__init__.py +0 -0
  69. sqlide/ui/widgets/completion_popup.py +81 -0
  70. sqlide/ui/widgets/connections_list.py +54 -0
  71. sqlide/ui/widgets/console_export.py +124 -0
  72. sqlide/ui/widgets/console_tab.py +335 -0
  73. sqlide/ui/widgets/console_tabs.py +117 -0
  74. sqlide/ui/widgets/result_grid.py +435 -0
  75. sqlide/ui/widgets/result_panel.py +73 -0
  76. sqlide/ui/widgets/result_view.py +50 -0
  77. sqlide/ui/widgets/schema_tree.py +145 -0
  78. sqlide/ui/widgets/sql_editor.py +310 -0
  79. sqlide/ui/widgets/status_bar.py +32 -0
  80. sqlide/workspace.py +97 -0
  81. sqlide-0.1.0.dist-info/METADATA +151 -0
  82. sqlide-0.1.0.dist-info/RECORD +85 -0
  83. sqlide-0.1.0.dist-info/WHEEL +4 -0
  84. sqlide-0.1.0.dist-info/entry_points.txt +2 -0
  85. sqlide-0.1.0.dist-info/licenses/LICENSE +21 -0
sqlide/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """sqlide: terminal SQL IDE over JDBC."""
2
+
3
+ __version__ = "0.1.0"
sqlide/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from sqlide.cli import main
4
+
5
+ sys.exit(main())
sqlide/app.py ADDED
@@ -0,0 +1,46 @@
1
+ """Textual application: only wires the workspace to the main screen."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from textual.app import App
8
+ from textual.binding import Binding
9
+
10
+ from sqlide.config.keymap import load_keymap
11
+ from sqlide.config.settings import save_settings
12
+ from sqlide.ui.commands import commands
13
+ from sqlide.ui.screens.main import MainScreen
14
+ from sqlide.workspace import Workspace
15
+
16
+
17
+ class SqlideApp(App):
18
+ CSS_PATH = str(Path(__file__).parent / "ui" / "app.tcss")
19
+ BINDINGS = [Binding("ctrl+q", "quit", "Quit", priority=True, id="app.quit")]
20
+
21
+ def __init__(self, workspace: Workspace | None = None, files: list[Path] | None = None) -> None:
22
+ super().__init__()
23
+ self.workspace = workspace or Workspace()
24
+ self._files = files or []
25
+
26
+ def on_mount(self) -> None:
27
+ self.set_keymap(load_keymap()) # user overrides from keymap.toml
28
+ if self.workspace.settings.theme in self.available_themes:
29
+ self.theme = self.workspace.settings.theme
30
+ self.theme_changed_signal.subscribe(self, self._remember_theme)
31
+ self.main = MainScreen(self.workspace, self._files)
32
+ self.push_screen(self.main)
33
+
34
+ def _remember_theme(self, theme) -> None:
35
+ settings = self.workspace.settings
36
+ if theme.name != settings.theme:
37
+ settings.theme = theme.name
38
+ save_settings(settings)
39
+
40
+ def get_system_commands(self, screen):
41
+ yield from super().get_system_commands(screen)
42
+ yield from commands(self)
43
+
44
+ async def action_quit(self) -> None:
45
+ await self.main.tabs.shutdown() # save consoles, cancel queries, close connections
46
+ self.exit()
sqlide/cli.py ADDED
@@ -0,0 +1,64 @@
1
+ """Command line entry point. Thin: dispatch only, no logic."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from sqlide import __version__
10
+ from sqlide.drivers import cli as driver_cli
11
+
12
+
13
+ def build_parser() -> argparse.ArgumentParser:
14
+ p = argparse.ArgumentParser(prog="sqlide", description="Terminal SQL IDE over JDBC")
15
+ p.add_argument("--version", action="version", version=f"sqlide {__version__}")
16
+ sub = p.add_subparsers(dest="command")
17
+ d = sub.add_parser("doctor", help="check environment (JVM, drivers, config dirs)")
18
+ d.set_defaults(handler=lambda _: _doctor())
19
+ k = sub.add_parser("keys", help="list rebindable keys (override in keymap.toml)")
20
+ k.set_defaults(handler=lambda _: _keys())
21
+ driver_cli.register(sub)
22
+ return p
23
+
24
+
25
+ def _doctor() -> int:
26
+ from sqlide import doctor
27
+
28
+ return doctor.run()
29
+
30
+
31
+ def _keys() -> int:
32
+ from sqlide.config import paths
33
+ from sqlide.ui.keymap import catalogue
34
+
35
+ print(f'Override in {paths.keymap_file()} ([keys] id = "key,key")\n')
36
+ infos = catalogue()
37
+ width = max(len(i.id) for i in infos)
38
+ for i in infos:
39
+ print(f"{i.id:<{width}} {i.keys:<40} {i.description}")
40
+ return 0
41
+
42
+
43
+ COMMANDS = {"doctor", "driver", "keys"}
44
+
45
+
46
+ def main(argv: list[str] | None = None) -> int:
47
+ argv = sys.argv[1:] if argv is None else argv
48
+ if argv and argv[0] not in COMMANDS and not argv[0].startswith("-"):
49
+ return _run_tui([Path(a) for a in argv]) # `sqlide query.sql other.sql`
50
+ args = build_parser().parse_args(argv)
51
+ if getattr(args, "handler", None):
52
+ return args.handler(args)
53
+ return _run_tui([])
54
+
55
+
56
+ def _run_tui(files: list[Path]) -> int:
57
+ from sqlide.app import SqlideApp
58
+
59
+ SqlideApp(files=files).run()
60
+ return 0
61
+
62
+
63
+ if __name__ == "__main__":
64
+ sys.exit(main())
sqlide/clipboard.py ADDED
@@ -0,0 +1,65 @@
1
+ """System clipboard across macOS, Linux (X11/Wayland), WSL; OSC52 as the last resort."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ from collections.abc import Callable
9
+ from pathlib import Path
10
+
11
+ Backend = tuple[str, list[str], Callable[[str], bytes]]
12
+
13
+
14
+ def _utf8(s: str) -> bytes:
15
+ return s.encode("utf-8")
16
+
17
+
18
+ def _utf16(s: str) -> bytes: # clip.exe reads UTF-16LE when a BOM is present
19
+ return b"\xff\xfe" + s.encode("utf-16-le")
20
+
21
+
22
+ def is_wsl() -> bool:
23
+ try:
24
+ return "microsoft" in Path("/proc/version").read_text().lower()
25
+ except OSError:
26
+ return False
27
+
28
+
29
+ def backends(platform: str | None = None, wsl: bool | None = None) -> list[Backend]:
30
+ """Installed native clipboard tools in preference order."""
31
+ import sys
32
+
33
+ platform = platform or sys.platform
34
+ wsl = is_wsl() if wsl is None else wsl
35
+ candidates: list[Backend] = []
36
+ if platform == "darwin":
37
+ candidates.append(("pbcopy", ["pbcopy"], _utf8))
38
+ elif platform == "win32":
39
+ candidates.append(("clip", ["clip"], _utf16))
40
+ else:
41
+ if os.environ.get("WAYLAND_DISPLAY"):
42
+ candidates.append(("wl-copy", ["wl-copy"], _utf8))
43
+ if os.environ.get("DISPLAY"):
44
+ candidates.append(("xclip", ["xclip", "-selection", "clipboard", "-i"], _utf8))
45
+ candidates.append(("xsel", ["xsel", "--clipboard", "--input"], _utf8))
46
+ if wsl:
47
+ candidates.append(("clip.exe", ["clip.exe"], _utf16))
48
+ return [c for c in candidates if shutil.which(c[1][0])]
49
+
50
+
51
+ def copy_native(text: str) -> str | None:
52
+ """Copy via the first working tool. Returns its name, or None if none worked."""
53
+ for name, cmd, encode in backends():
54
+ try:
55
+ subprocess.run(cmd, input=encode(text), check=True, timeout=5, capture_output=True)
56
+ return name
57
+ except (OSError, subprocess.SubprocessError):
58
+ continue
59
+ return None
60
+
61
+
62
+ def available() -> str:
63
+ """Human-readable description for `sqlide doctor`."""
64
+ found = backends()
65
+ return found[0][0] if found else "none (falls back to OSC52 terminal escape)"
@@ -0,0 +1 @@
1
+ """Configuration block: paths, settings, saved connections, password resolution."""
sqlide/config/_toml.py ADDED
@@ -0,0 +1,41 @@
1
+ """Tiny TOML IO shared by config modules: tolerant read, atomic write."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tempfile
7
+ import tomllib
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import tomli_w
12
+
13
+
14
+ class ConfigError(Exception):
15
+ """Config file is unreadable or invalid."""
16
+
17
+
18
+ def read_toml(path: Path) -> dict[str, Any]:
19
+ """Return {} when the file is missing; raise ConfigError when it is malformed."""
20
+ try:
21
+ with path.open("rb") as f:
22
+ return tomllib.load(f)
23
+ except FileNotFoundError:
24
+ return {}
25
+ except tomllib.TOMLDecodeError as e:
26
+ raise ConfigError(f"{path}: {e}") from e
27
+
28
+
29
+ def write_toml(path: Path, data: dict[str, Any], *, private: bool = False) -> None:
30
+ """Write atomically (temp file + rename) so a crash never leaves a half file."""
31
+ path.parent.mkdir(parents=True, exist_ok=True)
32
+ fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.")
33
+ try:
34
+ with os.fdopen(fd, "wb") as f:
35
+ tomli_w.dump(data, f)
36
+ if private:
37
+ os.chmod(tmp, 0o600)
38
+ os.replace(tmp, path)
39
+ except BaseException:
40
+ Path(tmp).unlink(missing_ok=True)
41
+ raise
@@ -0,0 +1,76 @@
1
+ """Saved connections. Passwords are never stored: only a reference or nothing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from sqlide.config import paths
11
+ from sqlide.config._toml import ConfigError, read_toml, write_toml
12
+
13
+ ENV_REF = re.compile(r"^\$\{env:([A-Za-z_][A-Za-z0-9_]*)\}$")
14
+
15
+
16
+ @dataclass(slots=True)
17
+ class Connection:
18
+ name: str
19
+ driver: str # driver id, see drivers registry
20
+ url: str # JDBC url, e.g. jdbc:postgresql://host:5432/db
21
+ user: str = ""
22
+ password_ref: str = "" # "" = ask on connect, or "${env:VAR}"
23
+ password_cmd: str = "" # shell-free command whose stdout is the password
24
+ properties: dict[str, str] = field(default_factory=dict)
25
+ autocommit: bool = True
26
+
27
+ def __post_init__(self) -> None:
28
+ if not self.name.strip():
29
+ raise ConfigError("connection name is empty")
30
+ if self.password_ref and not ENV_REF.match(self.password_ref):
31
+ raise ConfigError(
32
+ f"{self.name}: password_ref must look like ${{env:VAR}}, "
33
+ "plain passwords are not stored"
34
+ )
35
+
36
+
37
+ class ConnectionStore:
38
+ def __init__(self, path: Path | None = None) -> None:
39
+ self.path = path or paths.connections_file()
40
+
41
+ def load(self) -> list[Connection]:
42
+ raw = read_toml(self.path)
43
+ out = []
44
+ for item in raw.get("connection", []):
45
+ try:
46
+ out.append(Connection(**item))
47
+ except TypeError as e:
48
+ raise ConfigError(f"{self.path}: bad connection entry: {e}") from e
49
+ return out
50
+
51
+ def save(self, connections: list[Connection]) -> None:
52
+ names = [c.name for c in connections]
53
+ if len(names) != len(set(names)):
54
+ raise ConfigError("duplicate connection names")
55
+ data: dict[str, Any] = {"connection": [_to_dict(c) for c in connections]}
56
+ write_toml(self.path, data, private=True)
57
+
58
+ def upsert(self, conn: Connection) -> None:
59
+ items = [c for c in self.load() if c.name != conn.name]
60
+ self.save([*items, conn])
61
+
62
+ def remove(self, name: str) -> None:
63
+ self.save([c for c in self.load() if c.name != name])
64
+
65
+
66
+ def _to_dict(c: Connection) -> dict[str, Any]:
67
+ return {
68
+ "name": c.name,
69
+ "driver": c.driver,
70
+ "url": c.url,
71
+ "user": c.user,
72
+ "password_ref": c.password_ref,
73
+ "password_cmd": c.password_cmd,
74
+ "properties": c.properties,
75
+ "autocommit": c.autocommit,
76
+ }
@@ -0,0 +1,25 @@
1
+ """User key overrides: keymap.toml maps binding ids to key strings.
2
+
3
+ [keys]
4
+ "editor.run" = "f6"
5
+ "main.history" = "ctrl+h,alt+e" # several keys: comma separated
6
+
7
+ `sqlide keys` lists every id with its default. Bad entries are ignored, never fatal.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+
14
+ from sqlide.config import paths
15
+ from sqlide.config._toml import ConfigError, read_toml
16
+
17
+
18
+ def load_keymap(path: Path | None = None) -> dict[str, str]:
19
+ try:
20
+ raw = read_toml(path or paths.keymap_file()).get("keys", {})
21
+ except ConfigError:
22
+ return {}
23
+ if not isinstance(raw, dict):
24
+ return {}
25
+ return {k: v.strip() for k, v in raw.items() if isinstance(v, str) and v.strip()}
sqlide/config/paths.py ADDED
@@ -0,0 +1,48 @@
1
+ """Filesystem locations. Env overrides exist for tests and portable installs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ from platformdirs import user_config_path, user_data_path
9
+
10
+ APP = "sqlide"
11
+
12
+
13
+ def config_dir() -> Path:
14
+ override = os.environ.get("SQLIDE_CONFIG_DIR")
15
+ return Path(override) if override else user_config_path(APP)
16
+
17
+
18
+ def data_dir() -> Path:
19
+ override = os.environ.get("SQLIDE_DATA_DIR")
20
+ return Path(override) if override else user_data_path(APP)
21
+
22
+
23
+ def settings_file() -> Path:
24
+ return config_dir() / "settings.toml"
25
+
26
+
27
+ def connections_file() -> Path:
28
+ return config_dir() / "connections.toml"
29
+
30
+
31
+ def keymap_file() -> Path:
32
+ return config_dir() / "keymap.toml"
33
+
34
+
35
+ def drivers_file() -> Path:
36
+ return config_dir() / "drivers.toml"
37
+
38
+
39
+ def drivers_dir() -> Path:
40
+ return data_dir() / "drivers"
41
+
42
+
43
+ def history_db() -> Path:
44
+ return data_dir() / "history.sqlite"
45
+
46
+
47
+ def consoles_dir() -> Path:
48
+ return data_dir() / "consoles"
@@ -0,0 +1,82 @@
1
+ """Password resolution. Order: password_cmd -> ${env:VAR} -> prompt callback.
2
+
3
+ The session cache lives in memory only and dies with the process.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import shlex
10
+ import subprocess
11
+ from collections.abc import Callable
12
+
13
+ from sqlide.config._toml import ConfigError
14
+ from sqlide.config.connections import ENV_REF, Connection
15
+
16
+ CMD_TIMEOUT_S = 10
17
+
18
+ Prompt = Callable[[Connection], str | None] # None = user cancelled
19
+
20
+
21
+ def _split(cmd: str) -> list[str]:
22
+ if os.name != "nt":
23
+ return shlex.split(cmd)
24
+ # non-POSIX split keeps the quotes around tokens; strip them like CreateProcess would
25
+ return [
26
+ t[1:-1] if len(t) > 1 and t[0] == t[-1] == '"' else t for t in shlex.split(cmd, posix=False)
27
+ ]
28
+
29
+
30
+ class PasswordResolver:
31
+ def __init__(self) -> None:
32
+ self._cache: dict[str, str] = {}
33
+
34
+ def lookup(self, conn: Connection) -> str | None:
35
+ """Non-interactive sources only. None = the user has to be asked. May block (cmd)."""
36
+ if conn.name in self._cache:
37
+ return self._cache[conn.name]
38
+ password = self._from_cmd(conn) or self._from_env(conn)
39
+ if password is not None:
40
+ self._cache[conn.name] = password
41
+ return password
42
+
43
+ def store(self, name: str, password: str) -> None:
44
+ self._cache[name] = password
45
+
46
+ def resolve(self, conn: Connection, prompt: Prompt) -> str | None:
47
+ """Sync convenience: lookup, else ask via `prompt` (UI code uses lookup/store)."""
48
+ password = self.lookup(conn)
49
+ if password is None:
50
+ password = prompt(conn)
51
+ if password is not None:
52
+ self.store(conn.name, password)
53
+ return password
54
+
55
+ def forget(self, name: str) -> None:
56
+ self._cache.pop(name, None)
57
+
58
+ @staticmethod
59
+ def _from_cmd(conn: Connection) -> str | None:
60
+ if not conn.password_cmd:
61
+ return None
62
+ try:
63
+ res = subprocess.run(
64
+ _split(conn.password_cmd),
65
+ capture_output=True,
66
+ text=True,
67
+ timeout=CMD_TIMEOUT_S,
68
+ check=True,
69
+ )
70
+ except (OSError, subprocess.SubprocessError) as e:
71
+ raise ConfigError(f"{conn.name}: password_cmd failed: {e}") from e
72
+ return res.stdout.rstrip("\r\n")
73
+
74
+ @staticmethod
75
+ def _from_env(conn: Connection) -> str | None:
76
+ m = ENV_REF.match(conn.password_ref)
77
+ if not m:
78
+ return None
79
+ value = os.environ.get(m.group(1))
80
+ if value is None:
81
+ raise ConfigError(f"{conn.name}: env var {m.group(1)} is not set")
82
+ return value
@@ -0,0 +1,32 @@
1
+ """User settings. Unknown keys in the file are ignored; bad values fall back to defaults."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass, fields
6
+ from pathlib import Path
7
+
8
+ from sqlide.config import paths
9
+ from sqlide.config._toml import read_toml, write_toml
10
+
11
+
12
+ @dataclass(slots=True)
13
+ class Settings:
14
+ fetch_size: int = 500 # rows per page
15
+ split_on_blank_line: bool = True # blank line ends a statement (DataGrip-like)
16
+ autosave_consoles: bool = True
17
+ theme: str = "textual-dark"
18
+ history_limit: int = 5000
19
+
20
+
21
+ def load_settings(path: Path | None = None) -> Settings:
22
+ raw = read_toml(path or paths.settings_file())
23
+ defaults = Settings()
24
+ values = {}
25
+ for f in fields(Settings):
26
+ if f.name in raw and type(raw[f.name]) is type(getattr(defaults, f.name)):
27
+ values[f.name] = raw[f.name]
28
+ return Settings(**values)
29
+
30
+
31
+ def save_settings(settings: Settings, path: Path | None = None) -> None:
32
+ write_toml(path or paths.settings_file(), asdict(settings))
sqlide/consoles.py ADDED
@@ -0,0 +1,100 @@
1
+ """Console files and the list of open tabs. Pure storage: no UI, no JVM.
2
+
3
+ Consoles are scratch SQL files owned by the app: ``consoles/<connection>/console_N.sql``,
4
+ autosaved. Files opened by the user are saved only on request. The tab list is kept in
5
+ ``state.toml`` so the next start reopens the same tabs.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import re
12
+ import tempfile
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ from sqlide.config import paths
17
+ from sqlide.config._toml import ConfigError, read_toml, write_toml
18
+
19
+ CONSOLE, FILE = "console", "file"
20
+
21
+
22
+ @dataclass(slots=True)
23
+ class TabState:
24
+ kind: str # CONSOLE | FILE
25
+ path: str
26
+ connection: str = "" # intended connection name; connected lazily
27
+ active: bool = False
28
+
29
+
30
+ def safe_dirname(name: str) -> str:
31
+ return re.sub(r"[^\w.\-]+", "_", name).strip("._") or "_"
32
+
33
+
34
+ def write_text_atomic(path: Path, text: str) -> None:
35
+ path.parent.mkdir(parents=True, exist_ok=True)
36
+ fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.")
37
+ try:
38
+ with os.fdopen(fd, "w", encoding="utf-8", newline="") as f:
39
+ f.write(text)
40
+ os.replace(tmp, path)
41
+ except BaseException:
42
+ Path(tmp).unlink(missing_ok=True)
43
+ raise
44
+
45
+
46
+ class ConsoleStore:
47
+ def __init__(self, root: Path | None = None, state_file: Path | None = None) -> None:
48
+ self.root = root or paths.consoles_dir()
49
+ self.state_file = state_file or (paths.data_dir() / "state.toml")
50
+
51
+ def new_console_file(self, connection: str | None) -> Path:
52
+ """Reserve the next free ``console_N.sql`` for a connection (or no connection)."""
53
+ folder = self.root / safe_dirname(connection or "no-connection")
54
+ folder.mkdir(parents=True, exist_ok=True)
55
+ used = [
56
+ int(m.group(1))
57
+ for f in self.root.glob("*/console_*.sql")
58
+ if (m := re.fullmatch(r"console_(\d+)\.sql", f.name))
59
+ ]
60
+ n = max(used, default=0) + 1 # unique across connections, so tab titles differ
61
+ path = folder / f"console_{n}.sql"
62
+ path.touch()
63
+ return path
64
+
65
+ @staticmethod
66
+ def read(path: Path) -> tuple[str, str]:
67
+ """(text normalised to '\\n', original newline style) so saving can restore it."""
68
+ with path.open(encoding="utf-8", newline="") as f:
69
+ raw = f.read()
70
+ newline = "\r\n" if "\r\n" in raw else "\n"
71
+ return raw.replace("\r\n", "\n").replace("\r", "\n"), newline
72
+
73
+ @staticmethod
74
+ def write(path: Path, text: str, newline: str = "\n") -> None:
75
+ write_text_atomic(path, text.replace("\n", newline) if newline != "\n" else text)
76
+
77
+ # --- tab list ---
78
+ def load_state(self) -> list[TabState]:
79
+ try:
80
+ raw = read_toml(self.state_file).get("tab", [])
81
+ except ConfigError:
82
+ return [] # a damaged state file must never stop the app from starting
83
+ tabs = []
84
+ for item in raw:
85
+ try:
86
+ tabs.append(TabState(**item))
87
+ except TypeError:
88
+ continue
89
+ return [t for t in tabs if t.kind in (CONSOLE, FILE)]
90
+
91
+ def save_state(self, tabs: list[TabState]) -> None:
92
+ write_toml(
93
+ self.state_file,
94
+ {
95
+ "tab": [
96
+ {"kind": t.kind, "path": t.path, "connection": t.connection, "active": t.active}
97
+ for t in tabs
98
+ ]
99
+ },
100
+ )
sqlide/db/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """DB block: JDBC sessions, typed results. Knows nothing about the UI."""