Skip to content

Commit 1e2e4f1

Browse files
authored
feat: manage saved queries from explorer (#319)
Co-authored-by: Peter Adams <18162810+Maxteabag@users.noreply.github.com>
1 parent dabb8ef commit 1e2e4f1

7 files changed

Lines changed: 554 additions & 93 deletions

File tree

‎sqlit/core/keymap.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,9 @@ def _build_action_keys(self) -> list[ActionKeyDef]:
353353
ActionKeyDef("f", "refresh_tree", "tree"),
354354
ActionKeyDef("R", "refresh_tree", "tree", primary=False),
355355
ActionKeyDef("e", "edit_connection", "tree"),
356+
ActionKeyDef("r", "rename_saved_query", "tree"),
357+
ActionKeyDef("d", "delete_saved_query", "tree"),
358+
ActionKeyDef("delete", "delete_saved_query", "tree", primary=False),
356359
ActionKeyDef("M", "rename_connection_folder", "tree"),
357360
ActionKeyDef("d", "delete_connection_folder", "tree"),
358361
ActionKeyDef("delete", "delete_connection_folder", "tree", primary=False),

‎sqlit/domains/explorer/state/tree_on_folder.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,18 @@ def is_connection_folder(app: InputContext) -> bool:
2525
label="Delete",
2626
help="Delete connection folder",
2727
)
28+
self.allows(
29+
"rename_saved_query",
30+
lambda app: app.tree_node_kind == "saved_query_folder",
31+
label="Rename",
32+
help="Rename saved-query folder",
33+
)
34+
self.allows(
35+
"delete_saved_query",
36+
lambda app: app.tree_node_kind == "saved_query_folder",
37+
label="Delete",
38+
help="Delete saved-query folder",
39+
)
2840

2941
def get_display_bindings(self, app: InputContext) -> tuple[list[DisplayBinding], list[DisplayBinding]]:
3042
left: list[DisplayBinding] = []
@@ -58,6 +70,22 @@ def get_display_bindings(self, app: InputContext) -> tuple[list[DisplayBinding],
5870
)
5971
)
6072
seen.add("delete_connection_folder")
73+
elif app.tree_node_kind == "saved_query_folder":
74+
left.extend(
75+
(
76+
DisplayBinding(
77+
key=resolve_display_key("rename_saved_query") or "r",
78+
label="Rename",
79+
action="rename_saved_query",
80+
),
81+
DisplayBinding(
82+
key=resolve_display_key("delete_saved_query") or "d",
83+
label="Delete",
84+
action="delete_saved_query",
85+
),
86+
)
87+
)
88+
seen.update({"rename_saved_query", "delete_saved_query"})
6189

6290
if self.parent:
6391
parent_left, _ = self.parent.get_display_bindings(app)

‎sqlit/domains/explorer/state/tree_on_object.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,18 @@ class TreeOnObjectState(State):
1313

1414
def _setup_actions(self) -> None:
1515
self.allows("select_table", label="Open", help="Open the selected object")
16+
self.allows(
17+
"rename_saved_query",
18+
lambda app: app.tree_node_kind == "saved_query_file",
19+
label="Rename",
20+
help="Rename saved query",
21+
)
22+
self.allows(
23+
"delete_saved_query",
24+
lambda app: app.tree_node_kind == "saved_query_file",
25+
label="Delete",
26+
help="Delete saved query",
27+
)
1628

1729
def get_display_bindings(self, app: InputContext) -> tuple[list[DisplayBinding], list[DisplayBinding]]:
1830
left: list[DisplayBinding] = []
@@ -27,6 +39,22 @@ def get_display_bindings(self, app: InputContext) -> tuple[list[DisplayBinding],
2739
)
2840
)
2941
seen.add("select_table")
42+
if app.tree_node_kind == "saved_query_file":
43+
left.extend(
44+
(
45+
DisplayBinding(
46+
key=resolve_display_key("rename_saved_query") or "r",
47+
label="Rename",
48+
action="rename_saved_query",
49+
),
50+
DisplayBinding(
51+
key=resolve_display_key("delete_saved_query") or "d",
52+
label="Delete",
53+
action="delete_saved_query",
54+
),
55+
)
56+
)
57+
seen.update({"rename_saved_query", "delete_saved_query"})
3058
left.append(
3159
DisplayBinding(
3260
key=resolve_display_key("refresh_tree") or "f",

‎sqlit/domains/explorer/ui/mixins/tree.py‎

Lines changed: 209 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66

77
from textual.widgets import Tree
88

9-
from sqlit.domains.query.store.saved_queries import SavedQueryNameError
9+
from sqlit.domains.query.store.saved_queries import (
10+
SavedQueryConflictError,
11+
SavedQueryNameError,
12+
)
1013
from sqlit.shared.ui.protocols import TreeMixinHost
1114

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

97100
if self._get_node_kind(node) == "connection":
98101
config = getattr(data, "config", None)
99-
if (
100-
config
101-
and self.current_config
102-
and self.current_config.name == config.name
103-
and not list(node.children)
104-
):
102+
if config and self.current_config and self.current_config.name == config.name and not list(node.children):
105103
tree_builder.populate_connected_tree(self)
106104
return
107105

@@ -242,6 +240,181 @@ def open_query() -> None:
242240
else:
243241
open_query()
244242

243+
def _saved_query_document_moved(
244+
self: TreeMixinHost,
245+
connection_name: str,
246+
old_path: str,
247+
new_path: str,
248+
*,
249+
folder: bool,
250+
) -> None:
251+
document = getattr(self, "_query_document", None)
252+
if document is None or document.connection_name != connection_name:
253+
return
254+
current = document.relative_path
255+
if not current:
256+
return
257+
if folder:
258+
prefix = f"{old_path}/"
259+
if not current.startswith(prefix):
260+
return
261+
replacement = f"{new_path}/{current[len(prefix) :]}"
262+
elif current == old_path:
263+
replacement = new_path
264+
else:
265+
return
266+
setter = getattr(self, "_set_query_document", None)
267+
if callable(setter):
268+
setter(
269+
connection_name=connection_name,
270+
relative_path=replacement,
271+
saved_text=document.saved_text,
272+
fingerprint=document.fingerprint,
273+
)
274+
275+
def _saved_query_document_deleted(
276+
self: TreeMixinHost,
277+
connection_name: str,
278+
path: str,
279+
*,
280+
folder: bool,
281+
) -> None:
282+
document = getattr(self, "_query_document", None)
283+
if document is None or document.connection_name != connection_name:
284+
return
285+
current = document.relative_path
286+
matches = current == path or (folder and bool(current) and current.startswith(f"{path}/"))
287+
if not matches:
288+
return
289+
setter = getattr(self, "_set_query_document", None)
290+
if callable(setter):
291+
setter(
292+
connection_name=connection_name,
293+
relative_path=None,
294+
saved_text="",
295+
fingerprint=None,
296+
)
297+
298+
def action_rename_saved_query(self: TreeMixinHost) -> None:
299+
node = self.object_tree.cursor_node
300+
if not node or not node.data:
301+
return
302+
data = node.data
303+
kind = self._get_node_kind(node)
304+
if kind == "saved_query_file":
305+
from sqlit.domains.query.ui.screens import SavedQueryNameScreen
306+
307+
old_path = data.entry.relative_path
308+
309+
def on_name(name: str | None) -> None:
310+
if not name:
311+
return
312+
try:
313+
entry = self.services.saved_query_store.rename_file(
314+
data.connection_name,
315+
old_path,
316+
name,
317+
expected=data.entry.fingerprint,
318+
)
319+
except FileExistsError:
320+
self.notify("A saved query already uses that name", severity="warning")
321+
return
322+
except (OSError, SavedQueryConflictError, SavedQueryNameError) as exc:
323+
self.notify(f"Could not rename saved query: {exc}", severity="error")
324+
return
325+
self._saved_query_document_moved(
326+
data.connection_name,
327+
old_path,
328+
entry.relative_path,
329+
folder=False,
330+
)
331+
self._refresh_connection_tree()
332+
self.notify(f"Renamed to {entry.relative_path}")
333+
334+
self.push_screen(
335+
SavedQueryNameScreen(initial_name=old_path, title="Rename Saved Query"),
336+
on_name,
337+
)
338+
return
339+
if kind != "saved_query_folder" or not data.relative_path:
340+
return
341+
from sqlit.domains.connections.ui.screens import FolderInputScreen
342+
343+
old_path = data.relative_path
344+
345+
def on_folder(name: str | None) -> None:
346+
if not name:
347+
return
348+
try:
349+
new_path = self.services.saved_query_store.rename_folder(data.connection_name, old_path, name)
350+
except FileExistsError:
351+
self.notify("A saved-query folder already uses that name", severity="warning")
352+
return
353+
except (OSError, SavedQueryNameError) as exc:
354+
self.notify(f"Could not rename saved-query folder: {exc}", severity="error")
355+
return
356+
self._saved_query_document_moved(data.connection_name, old_path, new_path, folder=True)
357+
self._refresh_connection_tree()
358+
self.notify(f"Renamed folder to {new_path}")
359+
360+
self.push_screen(
361+
FolderInputScreen(
362+
data.connection_name,
363+
current_value=old_path,
364+
title="Rename Saved-Query Folder",
365+
description="New folder path (use / for nesting):",
366+
),
367+
on_folder,
368+
)
369+
370+
def action_delete_saved_query(self: TreeMixinHost) -> None:
371+
node = self.object_tree.cursor_node
372+
if not node or not node.data:
373+
return
374+
data = node.data
375+
kind = self._get_node_kind(node)
376+
if kind == "saved_query_file":
377+
path = data.entry.relative_path
378+
folder = False
379+
description = "The query text will remain open if this file is active."
380+
elif kind == "saved_query_folder" and data.relative_path:
381+
path = data.relative_path
382+
folder = True
383+
count = sum(1 for entry in self.services.saved_query_store.list_for_connection(data.connection_name) if entry.relative_path.startswith(f"{path}/"))
384+
description = f"This permanently deletes {count} saved query file{'s' if count != 1 else ''}."
385+
else:
386+
return
387+
from sqlit.shared.ui.screens.confirm import ConfirmScreen
388+
389+
def on_confirm(confirmed: bool | None) -> None:
390+
if not confirmed:
391+
return
392+
try:
393+
if folder:
394+
self.services.saved_query_store.delete_folder(data.connection_name, path)
395+
else:
396+
self.services.saved_query_store.delete_file(
397+
data.connection_name,
398+
path,
399+
expected=data.entry.fingerprint,
400+
)
401+
except (OSError, SavedQueryConflictError, SavedQueryNameError) as exc:
402+
self.notify(f"Could not delete saved query: {exc}", severity="error")
403+
return
404+
self._saved_query_document_deleted(data.connection_name, path, folder=folder)
405+
self._refresh_connection_tree()
406+
self.notify(f"Deleted {path}")
407+
408+
self.push_screen(
409+
ConfirmScreen(
410+
f"Delete {'folder' if folder else 'saved query'} '{path}'?",
411+
description,
412+
yes_label="Delete",
413+
no_label="Cancel",
414+
),
415+
on_confirm,
416+
)
417+
245418
def on_tree_node_highlighted(self: TreeMixinHost, event: Tree.NodeHighlighted) -> None:
246419
"""Update footer when tree selection changes."""
247420
self._update_footer_bindings()
@@ -445,9 +618,7 @@ async def work_async() -> None:
445618
incoming_fks: list[Any] = []
446619
try:
447620
runtime = getattr(self.services, "runtime", None)
448-
use_worker = bool(getattr(runtime, "process_worker", False)) and not bool(
449-
getattr(getattr(runtime, "mock", None), "enabled", False)
450-
)
621+
use_worker = bool(getattr(runtime, "process_worker", False)) and not bool(getattr(getattr(runtime, "mock", None), "enabled", False))
451622
client = None
452623
if use_worker and hasattr(self, "_get_process_worker_client_async"):
453624
client = await self._get_process_worker_client_async() # type: ignore[attr-defined]
@@ -473,28 +644,37 @@ async def work_async() -> None:
473644
schema_service = self._get_schema_service()
474645
if schema_service:
475646
if not columns:
476-
columns = await asyncio.to_thread(
477-
schema_service.list_columns,
478-
database,
479-
schema,
480-
name,
481-
) or []
647+
columns = (
648+
await asyncio.to_thread(
649+
schema_service.list_columns,
650+
database,
651+
schema,
652+
name,
653+
)
654+
or []
655+
)
482656
try:
483-
outgoing_fks = await asyncio.to_thread(
484-
schema_service.list_foreign_keys,
485-
database,
486-
schema,
487-
name,
488-
) or []
657+
outgoing_fks = (
658+
await asyncio.to_thread(
659+
schema_service.list_foreign_keys,
660+
database,
661+
schema,
662+
name,
663+
)
664+
or []
665+
)
489666
except Exception:
490667
outgoing_fks = []
491668
try:
492-
incoming_fks = await asyncio.to_thread(
493-
schema_service.list_referencing_foreign_keys,
494-
database,
495-
schema,
496-
name,
497-
) or []
669+
incoming_fks = (
670+
await asyncio.to_thread(
671+
schema_service.list_referencing_foreign_keys,
672+
database,
673+
schema,
674+
name,
675+
)
676+
or []
677+
)
498678
except Exception:
499679
incoming_fks = []
500680
except Exception:
@@ -530,11 +710,7 @@ def _apply_last_query_table_columns(
530710
table_info = getattr(self, "_last_query_table", None)
531711
if not table_info:
532712
return
533-
if (
534-
table_info.get("database") != database
535-
or table_info.get("schema") != schema
536-
or table_info.get("name") != name
537-
):
713+
if table_info.get("database") != database or table_info.get("schema") != schema or table_info.get("name") != name:
538714
return
539715
table_info["columns"] = columns
540716
if foreign_keys is not None:

0 commit comments

Comments
 (0)