Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions sqlit/core/keymap.py
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,9 @@ def _build_action_keys(self) -> list[ActionKeyDef]:
ActionKeyDef("f", "refresh_tree", "tree"),
ActionKeyDef("R", "refresh_tree", "tree", primary=False),
ActionKeyDef("e", "edit_connection", "tree"),
ActionKeyDef("r", "rename_saved_query", "tree"),
ActionKeyDef("d", "delete_saved_query", "tree"),
ActionKeyDef("delete", "delete_saved_query", "tree", primary=False),
ActionKeyDef("M", "rename_connection_folder", "tree"),
ActionKeyDef("d", "delete_connection_folder", "tree"),
ActionKeyDef("delete", "delete_connection_folder", "tree", primary=False),
Expand Down
28 changes: 28 additions & 0 deletions sqlit/domains/explorer/state/tree_on_folder.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@ def is_connection_folder(app: InputContext) -> bool:
label="Delete",
help="Delete connection folder",
)
self.allows(
"rename_saved_query",
lambda app: app.tree_node_kind == "saved_query_folder",
label="Rename",
help="Rename saved-query folder",
)
self.allows(
"delete_saved_query",
lambda app: app.tree_node_kind == "saved_query_folder",
label="Delete",
help="Delete saved-query folder",
)

def get_display_bindings(self, app: InputContext) -> tuple[list[DisplayBinding], list[DisplayBinding]]:
left: list[DisplayBinding] = []
Expand Down Expand Up @@ -58,6 +70,22 @@ def get_display_bindings(self, app: InputContext) -> tuple[list[DisplayBinding],
)
)
seen.add("delete_connection_folder")
elif app.tree_node_kind == "saved_query_folder":
left.extend(
(
DisplayBinding(
key=resolve_display_key("rename_saved_query") or "r",
label="Rename",
action="rename_saved_query",
),
DisplayBinding(
key=resolve_display_key("delete_saved_query") or "d",
label="Delete",
action="delete_saved_query",
),
)
)
seen.update({"rename_saved_query", "delete_saved_query"})

if self.parent:
parent_left, _ = self.parent.get_display_bindings(app)
Expand Down
28 changes: 28 additions & 0 deletions sqlit/domains/explorer/state/tree_on_object.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,18 @@ class TreeOnObjectState(State):

def _setup_actions(self) -> None:
self.allows("select_table", label="Open", help="Open the selected object")
self.allows(
"rename_saved_query",
lambda app: app.tree_node_kind == "saved_query_file",
label="Rename",
help="Rename saved query",
)
self.allows(
"delete_saved_query",
lambda app: app.tree_node_kind == "saved_query_file",
label="Delete",
help="Delete saved query",
)

def get_display_bindings(self, app: InputContext) -> tuple[list[DisplayBinding], list[DisplayBinding]]:
left: list[DisplayBinding] = []
Expand All @@ -27,6 +39,22 @@ def get_display_bindings(self, app: InputContext) -> tuple[list[DisplayBinding],
)
)
seen.add("select_table")
if app.tree_node_kind == "saved_query_file":
left.extend(
(
DisplayBinding(
key=resolve_display_key("rename_saved_query") or "r",
label="Rename",
action="rename_saved_query",
),
DisplayBinding(
key=resolve_display_key("delete_saved_query") or "d",
label="Delete",
action="delete_saved_query",
),
)
)
seen.update({"rename_saved_query", "delete_saved_query"})
left.append(
DisplayBinding(
key=resolve_display_key("refresh_tree") or "f",
Expand Down
242 changes: 209 additions & 33 deletions sqlit/domains/explorer/ui/mixins/tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@

from textual.widgets import Tree

from sqlit.domains.query.store.saved_queries import SavedQueryNameError
from sqlit.domains.query.store.saved_queries import (
SavedQueryConflictError,
SavedQueryNameError,
)
from sqlit.shared.ui.protocols import TreeMixinHost

from ..tree import builder as tree_builder
Expand Down Expand Up @@ -96,12 +99,7 @@ def on_tree_node_expanded(self: TreeMixinHost, event: Tree.NodeExpanded) -> None

if self._get_node_kind(node) == "connection":
config = getattr(data, "config", None)
if (
config
and self.current_config
and self.current_config.name == config.name
and not list(node.children)
):
if config and self.current_config and self.current_config.name == config.name and not list(node.children):
tree_builder.populate_connected_tree(self)
return

Expand Down Expand Up @@ -242,6 +240,181 @@ def open_query() -> None:
else:
open_query()

def _saved_query_document_moved(
self: TreeMixinHost,
connection_name: str,
old_path: str,
new_path: str,
*,
folder: bool,
) -> None:
document = getattr(self, "_query_document", None)
if document is None or document.connection_name != connection_name:
return
current = document.relative_path
if not current:
return
if folder:
prefix = f"{old_path}/"
if not current.startswith(prefix):
return
replacement = f"{new_path}/{current[len(prefix) :]}"
elif current == old_path:
replacement = new_path
else:
return
setter = getattr(self, "_set_query_document", None)
if callable(setter):
setter(
connection_name=connection_name,
relative_path=replacement,
saved_text=document.saved_text,
fingerprint=document.fingerprint,
)

def _saved_query_document_deleted(
self: TreeMixinHost,
connection_name: str,
path: str,
*,
folder: bool,
) -> None:
document = getattr(self, "_query_document", None)
if document is None or document.connection_name != connection_name:
return
current = document.relative_path
matches = current == path or (folder and bool(current) and current.startswith(f"{path}/"))
if not matches:
return
setter = getattr(self, "_set_query_document", None)
if callable(setter):
setter(
connection_name=connection_name,
relative_path=None,
saved_text="",
fingerprint=None,
)

def action_rename_saved_query(self: TreeMixinHost) -> None:
node = self.object_tree.cursor_node
if not node or not node.data:
return
data = node.data
kind = self._get_node_kind(node)
if kind == "saved_query_file":
from sqlit.domains.query.ui.screens import SavedQueryNameScreen

old_path = data.entry.relative_path

def on_name(name: str | None) -> None:
if not name:
return
try:
entry = self.services.saved_query_store.rename_file(
data.connection_name,
old_path,
name,
expected=data.entry.fingerprint,
)
except FileExistsError:
self.notify("A saved query already uses that name", severity="warning")
return
except (OSError, SavedQueryConflictError, SavedQueryNameError) as exc:
self.notify(f"Could not rename saved query: {exc}", severity="error")
return
self._saved_query_document_moved(
data.connection_name,
old_path,
entry.relative_path,
folder=False,
)
self._refresh_connection_tree()
self.notify(f"Renamed to {entry.relative_path}")

self.push_screen(
SavedQueryNameScreen(initial_name=old_path, title="Rename Saved Query"),
on_name,
)
return
if kind != "saved_query_folder" or not data.relative_path:
return
from sqlit.domains.connections.ui.screens import FolderInputScreen

old_path = data.relative_path

def on_folder(name: str | None) -> None:
if not name:
return
try:
new_path = self.services.saved_query_store.rename_folder(data.connection_name, old_path, name)
except FileExistsError:
self.notify("A saved-query folder already uses that name", severity="warning")
return
except (OSError, SavedQueryNameError) as exc:
self.notify(f"Could not rename saved-query folder: {exc}", severity="error")
return
self._saved_query_document_moved(data.connection_name, old_path, new_path, folder=True)
self._refresh_connection_tree()
self.notify(f"Renamed folder to {new_path}")

self.push_screen(
FolderInputScreen(
data.connection_name,
current_value=old_path,
title="Rename Saved-Query Folder",
description="New folder path (use / for nesting):",
),
on_folder,
)

def action_delete_saved_query(self: TreeMixinHost) -> None:
node = self.object_tree.cursor_node
if not node or not node.data:
return
data = node.data
kind = self._get_node_kind(node)
if kind == "saved_query_file":
path = data.entry.relative_path
folder = False
description = "The query text will remain open if this file is active."
elif kind == "saved_query_folder" and data.relative_path:
path = data.relative_path
folder = True
count = sum(1 for entry in self.services.saved_query_store.list_for_connection(data.connection_name) if entry.relative_path.startswith(f"{path}/"))
description = f"This permanently deletes {count} saved query file{'s' if count != 1 else ''}."
else:
return
from sqlit.shared.ui.screens.confirm import ConfirmScreen

def on_confirm(confirmed: bool | None) -> None:
if not confirmed:
return
try:
if folder:
self.services.saved_query_store.delete_folder(data.connection_name, path)
else:
self.services.saved_query_store.delete_file(
data.connection_name,
path,
expected=data.entry.fingerprint,
)
except (OSError, SavedQueryConflictError, SavedQueryNameError) as exc:
self.notify(f"Could not delete saved query: {exc}", severity="error")
return
self._saved_query_document_deleted(data.connection_name, path, folder=folder)
self._refresh_connection_tree()
self.notify(f"Deleted {path}")

self.push_screen(
ConfirmScreen(
f"Delete {'folder' if folder else 'saved query'} '{path}'?",
description,
yes_label="Delete",
no_label="Cancel",
),
on_confirm,
)

def on_tree_node_highlighted(self: TreeMixinHost, event: Tree.NodeHighlighted) -> None:
"""Update footer when tree selection changes."""
self._update_footer_bindings()
Expand Down Expand Up @@ -445,9 +618,7 @@ async def work_async() -> None:
incoming_fks: list[Any] = []
try:
runtime = getattr(self.services, "runtime", None)
use_worker = bool(getattr(runtime, "process_worker", False)) and not bool(
getattr(getattr(runtime, "mock", None), "enabled", False)
)
use_worker = bool(getattr(runtime, "process_worker", False)) and not bool(getattr(getattr(runtime, "mock", None), "enabled", False))
client = None
if use_worker and hasattr(self, "_get_process_worker_client_async"):
client = await self._get_process_worker_client_async() # type: ignore[attr-defined]
Expand All @@ -473,28 +644,37 @@ async def work_async() -> None:
schema_service = self._get_schema_service()
if schema_service:
if not columns:
columns = await asyncio.to_thread(
schema_service.list_columns,
database,
schema,
name,
) or []
columns = (
await asyncio.to_thread(
schema_service.list_columns,
database,
schema,
name,
)
or []
)
try:
outgoing_fks = await asyncio.to_thread(
schema_service.list_foreign_keys,
database,
schema,
name,
) or []
outgoing_fks = (
await asyncio.to_thread(
schema_service.list_foreign_keys,
database,
schema,
name,
)
or []
)
except Exception:
outgoing_fks = []
try:
incoming_fks = await asyncio.to_thread(
schema_service.list_referencing_foreign_keys,
database,
schema,
name,
) or []
incoming_fks = (
await asyncio.to_thread(
schema_service.list_referencing_foreign_keys,
database,
schema,
name,
)
or []
)
except Exception:
incoming_fks = []
except Exception:
Expand Down Expand Up @@ -530,11 +710,7 @@ def _apply_last_query_table_columns(
table_info = getattr(self, "_last_query_table", None)
if not table_info:
return
if (
table_info.get("database") != database
or table_info.get("schema") != schema
or table_info.get("name") != name
):
if table_info.get("database") != database or table_info.get("schema") != schema or table_info.get("name") != name:
return
table_info["columns"] = columns
if foreign_keys is not None:
Expand Down
Loading
Loading