Skip to content

Commit a9d7f03

Browse files
committed
Add hierarchal connections
1 parent eba2e67 commit a9d7f03

File tree

18 files changed

+663
-155
lines changed

18 files changed

+663
-155
lines changed

sqlit/core/keymap.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,10 +296,14 @@ def _build_action_keys(self) -> list[ActionKeyDef]:
296296
ActionKeyDef("f", "refresh_tree", "tree"),
297297
ActionKeyDef("R", "refresh_tree", "tree", primary=False),
298298
ActionKeyDef("e", "edit_connection", "tree"),
299+
ActionKeyDef("M", "rename_connection_folder", "tree"),
300+
ActionKeyDef("d", "delete_connection_folder", "tree"),
301+
ActionKeyDef("delete", "delete_connection_folder", "tree", primary=False),
299302
ActionKeyDef("d", "delete_connection", "tree"),
300303
ActionKeyDef("delete", "delete_connection", "tree", primary=False),
301304
ActionKeyDef("D", "duplicate_connection", "tree"),
302305
ActionKeyDef("asterisk", "toggle_connection_favorite", "tree"),
306+
ActionKeyDef("m", "move_connection_to_folder", "tree"),
303307
ActionKeyDef("x", "disconnect", "tree"),
304308
ActionKeyDef("z", "collapse_tree", "tree"),
305309
ActionKeyDef("j", "tree_cursor_down", "tree"),

sqlit/domains/connections/domain/config.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ class ConnectionConfig:
127127
source: str | None = None
128128
connection_url: str | None = None
129129
favorite: bool = False
130+
folder_path: str = ""
130131
extra_options: dict[str, str] = field(default_factory=dict)
131132
options: dict[str, Any] = field(default_factory=dict)
132133

@@ -218,6 +219,7 @@ def from_dict(cls, data: Mapping[str, Any]) -> ConnectionConfig:
218219
"source",
219220
"connection_url",
220221
"favorite",
222+
"folder_path",
221223
"extra_options",
222224
}
223225
for key in list(payload.keys()):
@@ -232,6 +234,7 @@ def from_dict(cls, data: Mapping[str, Any]) -> ConnectionConfig:
232234
favorite = bool(raw_favorite)
233235
if isinstance(raw_favorite, str):
234236
favorite = raw_favorite.strip().lower() in {"1", "true", "yes", "y", "on"}
237+
folder_path = normalize_folder_path(payload.get("folder_path", ""))
235238

236239
return cls(
237240
name=str(payload.get("name", "")),
@@ -241,6 +244,7 @@ def from_dict(cls, data: Mapping[str, Any]) -> ConnectionConfig:
241244
source=payload.get("source"),
242245
connection_url=payload.get("connection_url"),
243246
favorite=favorite,
247+
folder_path=folder_path,
244248
extra_options=dict(payload.get("extra_options") or {}),
245249
options=options,
246250
)
@@ -308,6 +312,7 @@ def to_dict(self, *, include_passwords: bool = True) -> dict[str, Any]:
308312
"source": self.source,
309313
"connection_url": self.connection_url,
310314
"favorite": self.favorite,
315+
"folder_path": self.folder_path,
311316
"extra_options": dict(self.extra_options),
312317
"options": dict(self.options),
313318
}
@@ -542,3 +547,13 @@ def get_source_emoji(source: str | None) -> str:
542547
if source is None:
543548
return ""
544549
return SOURCE_EMOJIS.get(source, "")
550+
551+
552+
def normalize_folder_path(path: str | None) -> str:
553+
if not path:
554+
return ""
555+
if not isinstance(path, str):
556+
path = str(path)
557+
path = path.replace("\\", "/")
558+
parts = [part.strip() for part in path.split("/") if part.strip()]
559+
return "/".join(parts)

sqlit/domains/connections/ui/mixins/connection.py

Lines changed: 216 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,35 @@ def _get_connection_config_from_node(self, node: Any) -> ConnectionConfig | None
145145
data = getattr(node, "data", None)
146146
return self._get_connection_config_from_data(data)
147147

148+
def _find_connection_node_by_name(self: ConnectionMixinHost, name: str) -> Any | None:
149+
if not name:
150+
return None
151+
stack = [self.object_tree.root]
152+
while stack:
153+
node = stack.pop()
154+
for child in node.children:
155+
config = self._get_connection_config_from_node(child)
156+
if config and config.name == name:
157+
return child
158+
stack.append(child)
159+
return None
160+
161+
def _get_connection_folder_path(self: ConnectionMixinHost, node: Any) -> str | None:
162+
if not node or self._get_node_kind(node) != "connection_folder":
163+
return None
164+
parts: list[str] = []
165+
current = node
166+
while current and current != self.object_tree.root:
167+
if self._get_node_kind(current) == "connection_folder":
168+
data = getattr(current, "data", None)
169+
name = getattr(data, "name", None)
170+
if isinstance(name, str) and name:
171+
parts.append(name)
172+
current = current.parent
173+
if not parts:
174+
return None
175+
return "/".join(reversed(parts))
176+
148177
def connect_to_server(self: ConnectionMixinHost, config: ConnectionConfig) -> None:
149178
"""Connect to a database (async, non-blocking).
150179
@@ -369,11 +398,9 @@ def _select_connected_node(self: ConnectionMixinHost) -> None:
369398
cursor_config = self._get_connection_config_from_node(cursor)
370399
if not cursor_config or cursor_config.name != self.current_config.name:
371400
return
372-
for node in self.object_tree.root.children:
373-
config = self._get_connection_config_from_node(node)
374-
if config and config.name == self.current_config.name:
375-
self.object_tree.move_cursor(node)
376-
break
401+
node = self._find_connection_node_by_name(self.current_config.name)
402+
if node is not None:
403+
self.object_tree.move_cursor(node)
377404

378405
def action_disconnect(self: ConnectionMixinHost) -> None:
379406
"""Disconnect from current database."""
@@ -619,6 +646,179 @@ def action_toggle_connection_favorite(self: ConnectionMixinHost) -> None:
619646
if credentials_error:
620647
self.push_screen(ErrorScreen("Keyring Error", str(credentials_error)))
621648

649+
def action_move_connection_to_folder(self: ConnectionMixinHost) -> None:
650+
from sqlit.domains.connections.app.credentials import CredentialsPersistError
651+
from sqlit.domains.connections.domain.config import normalize_folder_path
652+
from sqlit.domains.connections.ui.screens import FolderInputScreen
653+
from sqlit.shared.ui.screens.error import ErrorScreen
654+
655+
node = self.object_tree.cursor_node
656+
if not node:
657+
return
658+
659+
config = self._get_connection_config_from_node(node)
660+
if not config:
661+
return
662+
663+
if not any(c.name == config.name for c in self.connections):
664+
self.notify("Only saved connections can be moved", severity="warning")
665+
return
666+
667+
current_path = getattr(config, "folder_path", "")
668+
669+
def on_result(value: str | None) -> None:
670+
if value is None:
671+
return
672+
new_path = normalize_folder_path(value)
673+
if new_path == current_path:
674+
return
675+
config.folder_path = new_path
676+
credentials_error: CredentialsPersistError | None = None
677+
678+
try:
679+
self.services.connection_store.save_all(self.connections)
680+
except CredentialsPersistError as exc:
681+
credentials_error = exc
682+
except Exception as exc:
683+
config.folder_path = current_path
684+
self.notify(f"Failed to move connection: {exc}", severity="error")
685+
return
686+
687+
if not self.services.connection_store.is_persistent:
688+
self.notify("Connections are not persisted in this session", severity="warning")
689+
690+
self._refresh_connection_tree()
691+
if new_path:
692+
self.notify(f"Moved to folder '{new_path}'")
693+
else:
694+
self.notify("Moved to root")
695+
if credentials_error:
696+
self.push_screen(ErrorScreen("Keyring Error", str(credentials_error)))
697+
698+
self.push_screen(
699+
FolderInputScreen(config.name, current_value=current_path),
700+
on_result,
701+
)
702+
703+
def action_rename_connection_folder(self: ConnectionMixinHost) -> None:
704+
from sqlit.domains.connections.app.credentials import CredentialsPersistError
705+
from sqlit.domains.connections.domain.config import normalize_folder_path
706+
from sqlit.domains.connections.ui.screens import FolderInputScreen
707+
from sqlit.shared.ui.screens.error import ErrorScreen
708+
709+
node = self.object_tree.cursor_node
710+
folder_path = self._get_connection_folder_path(node)
711+
if not folder_path:
712+
return
713+
714+
parent_path = "/".join(folder_path.split("/")[:-1])
715+
current_name = folder_path.split("/")[-1]
716+
717+
def on_result(value: str | None) -> None:
718+
if value is None:
719+
return
720+
new_segment = normalize_folder_path(value)
721+
if not new_segment:
722+
self.notify("Folder name cannot be empty", severity="warning")
723+
return
724+
new_path = f"{parent_path}/{new_segment}" if parent_path else new_segment
725+
if new_path == folder_path:
726+
return
727+
728+
updated = False
729+
for conn in self.connections:
730+
path = getattr(conn, "folder_path", "") or ""
731+
if path == folder_path or path.startswith(f"{folder_path}/"):
732+
remainder = "" if path == folder_path else path[len(folder_path) + 1 :]
733+
conn.folder_path = f"{new_path}/{remainder}" if remainder else new_path
734+
updated = True
735+
736+
if not updated:
737+
return
738+
739+
credentials_error: CredentialsPersistError | None = None
740+
try:
741+
self.services.connection_store.save_all(self.connections)
742+
except CredentialsPersistError as exc:
743+
credentials_error = exc
744+
except Exception as exc:
745+
self.notify(f"Failed to rename folder: {exc}", severity="error")
746+
return
747+
748+
if not self.services.connection_store.is_persistent:
749+
self.notify("Connections are not persisted in this session", severity="warning")
750+
751+
self._refresh_connection_tree()
752+
self.notify(f"Renamed folder to '{new_path}'")
753+
if credentials_error:
754+
self.push_screen(ErrorScreen("Keyring Error", str(credentials_error)))
755+
756+
self.push_screen(
757+
FolderInputScreen(
758+
current_name,
759+
current_value=current_name,
760+
title="Rename Folder",
761+
description=f"Rename folder '{folder_path}' (use / for nesting):",
762+
),
763+
on_result,
764+
)
765+
766+
def action_delete_connection_folder(self: ConnectionMixinHost) -> None:
767+
from sqlit.domains.connections.app.credentials import CredentialsPersistError
768+
from sqlit.shared.ui.screens.confirm import ConfirmScreen
769+
from sqlit.shared.ui.screens.error import ErrorScreen
770+
771+
node = self.object_tree.cursor_node
772+
folder_path = self._get_connection_folder_path(node)
773+
if not folder_path:
774+
return
775+
776+
parent_path = "/".join(folder_path.split("/")[:-1])
777+
778+
def do_delete(confirmed: bool | None) -> None:
779+
if not confirmed:
780+
return
781+
782+
updated = False
783+
for conn in self.connections:
784+
path = getattr(conn, "folder_path", "") or ""
785+
if path == folder_path or path.startswith(f"{folder_path}/"):
786+
remainder = "" if path == folder_path else path[len(folder_path) + 1 :]
787+
if parent_path:
788+
new_path = f"{parent_path}/{remainder}" if remainder else parent_path
789+
else:
790+
new_path = remainder
791+
conn.folder_path = new_path
792+
updated = True
793+
794+
if not updated:
795+
return
796+
797+
credentials_error: CredentialsPersistError | None = None
798+
try:
799+
self.services.connection_store.save_all(self.connections)
800+
except CredentialsPersistError as exc:
801+
credentials_error = exc
802+
except Exception as exc:
803+
self.notify(f"Failed to delete folder: {exc}", severity="error")
804+
return
805+
806+
if not self.services.connection_store.is_persistent:
807+
self.notify("Connections are not persisted in this session", severity="warning")
808+
809+
self._refresh_connection_tree()
810+
self.notify(f"Deleted folder '{folder_path}'")
811+
if credentials_error:
812+
self.push_screen(ErrorScreen("Keyring Error", str(credentials_error)))
813+
814+
self.push_screen(
815+
ConfirmScreen(
816+
f"Delete folder '{folder_path}'?",
817+
"Connections will move to the parent folder.",
818+
),
819+
do_delete,
820+
)
821+
622822
def action_delete_connection(self: ConnectionMixinHost) -> None:
623823
from sqlit.shared.ui.screens.confirm import ConfirmScreen
624824

@@ -708,15 +908,13 @@ def _handle_connection_picker_result(self: ConnectionMixinHost, result: Any) ->
708908
matching_config = next((c for c in self.connections if c.name == config.name), None)
709909
if matching_config:
710910
config = matching_config
711-
for node in self.object_tree.root.children:
712-
node_config = self._get_connection_config_from_node(node)
713-
if node_config and node_config.name == config.name:
714-
self._emit_debug(
715-
"connection_picker.select_node",
716-
connection=config.name,
717-
)
718-
self.object_tree.move_cursor(node)
719-
break
911+
node = self._find_connection_node_by_name(config.name)
912+
if node is not None:
913+
self._emit_debug(
914+
"connection_picker.select_node",
915+
connection=config.name,
916+
)
917+
self.object_tree.move_cursor(node)
720918
if self.current_config and self.current_config.name == config.name:
721919
self._emit_debug("connection_picker.already_connected", connection=config.name)
722920
self.notify(f"Already connected to {config.name}")
@@ -728,12 +926,10 @@ def _handle_connection_picker_result(self: ConnectionMixinHost, result: Any) ->
728926
selected_config = next((c for c in self.connections if c.name == result), None)
729927
if selected_config:
730928
self._emit_debug("connection_picker.result", result="name", connection=selected_config.name)
731-
for node in self.object_tree.root.children:
732-
node_config = self._get_connection_config_from_node(node)
733-
if node_config and node_config.name == result:
734-
self._emit_debug("connection_picker.select_node", connection=selected_config.name)
735-
self.object_tree.move_cursor(node)
736-
break
929+
node = self._find_connection_node_by_name(result)
930+
if node is not None:
931+
self._emit_debug("connection_picker.select_node", connection=selected_config.name)
932+
self.object_tree.move_cursor(node)
737933

738934
if self.current_config and self.current_config.name == selected_config.name:
739935
self._emit_debug("connection_picker.already_connected", connection=selected_config.name)

sqlit/domains/connections/ui/screens/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from .azure_firewall import AzureFirewallScreen
44
from .connection import ConnectionScreen
55
from .connection_picker import ConnectionPickerScreen
6+
from .folder_input import FolderInputScreen
67
from .install_progress import InstallProgressScreen
78
from .package_setup import PackageSetupScreen
89
from .password_input import PasswordInputScreen
@@ -11,6 +12,7 @@
1112
"AzureFirewallScreen",
1213
"ConnectionPickerScreen",
1314
"ConnectionScreen",
15+
"FolderInputScreen",
1416
"InstallProgressScreen",
1517
"PackageSetupScreen",
1618
"PasswordInputScreen",

sqlit/domains/connections/ui/screens/connection.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,6 +658,7 @@ def _get_config(self) -> ConnectionConfig | None:
658658
config_data["tunnel"] = tunnel
659659
if self.editing and self.config is not None:
660660
config_data["favorite"] = getattr(self.config, "favorite", False)
661+
config_data["folder_path"] = getattr(self.config, "folder_path", "")
661662

662663
config = ConnectionConfig.from_dict(config_data)
663664
from sqlit.domains.connections.providers.config_service import normalize_connection_config

0 commit comments

Comments
 (0)