Skip to content

Commit eba2e67

Browse files
committed
Add connection star
1 parent d4681a1 commit eba2e67

13 files changed

Lines changed: 177 additions & 15 deletions

File tree

sqlit/core/keymap.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
KEY_DISPLAY_OVERRIDES: dict[str, str] = {
1111
"question_mark": "?",
1212
"slash": "/",
13+
"asterisk": "*",
1314
"space": "<space>",
1415
"escape": "<esc>",
1516
"enter": "<enter>",
@@ -298,6 +299,7 @@ def _build_action_keys(self) -> list[ActionKeyDef]:
298299
ActionKeyDef("d", "delete_connection", "tree"),
299300
ActionKeyDef("delete", "delete_connection", "tree", primary=False),
300301
ActionKeyDef("D", "duplicate_connection", "tree"),
302+
ActionKeyDef("asterisk", "toggle_connection_favorite", "tree"),
301303
ActionKeyDef("x", "disconnect", "tree"),
302304
ActionKeyDef("z", "collapse_tree", "tree"),
303305
ActionKeyDef("j", "tree_cursor_down", "tree"),

sqlit/domains/connections/domain/config.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ class ConnectionConfig:
126126
tunnel: TunnelConfig | None = None
127127
source: str | None = None
128128
connection_url: str | None = None
129+
favorite: bool = False
129130
extra_options: dict[str, str] = field(default_factory=dict)
130131
options: dict[str, Any] = field(default_factory=dict)
131132

@@ -216,6 +217,7 @@ def from_dict(cls, data: Mapping[str, Any]) -> ConnectionConfig:
216217
"db_type",
217218
"source",
218219
"connection_url",
220+
"favorite",
219221
"extra_options",
220222
}
221223
for key in list(payload.keys()):
@@ -226,13 +228,19 @@ def from_dict(cls, data: Mapping[str, Any]) -> ConnectionConfig:
226228
else:
227229
payload.pop(key)
228230

231+
raw_favorite = payload.get("favorite", False)
232+
favorite = bool(raw_favorite)
233+
if isinstance(raw_favorite, str):
234+
favorite = raw_favorite.strip().lower() in {"1", "true", "yes", "y", "on"}
235+
229236
return cls(
230237
name=str(payload.get("name", "")),
231238
db_type=str(payload.get("db_type", "mssql")),
232239
endpoint=endpoint,
233240
tunnel=tunnel,
234241
source=payload.get("source"),
235242
connection_url=payload.get("connection_url"),
243+
favorite=favorite,
236244
extra_options=dict(payload.get("extra_options") or {}),
237245
options=options,
238246
)
@@ -299,6 +307,7 @@ def to_dict(self, *, include_passwords: bool = True) -> dict[str, Any]:
299307
"db_type": self.db_type,
300308
"source": self.source,
301309
"connection_url": self.connection_url,
310+
"favorite": self.favorite,
302311
"extra_options": dict(self.extra_options),
303312
"options": dict(self.options),
304313
}

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,46 @@ def action_duplicate_connection(self: ConnectionMixinHost) -> None:
579579
self._set_connection_screen_footer()
580580
self.push_screen(ConnectionScreen(duplicated, editing=False), self._wrap_connection_result)
581581

582+
def action_toggle_connection_favorite(self: ConnectionMixinHost) -> None:
583+
from sqlit.domains.connections.app.credentials import CredentialsPersistError
584+
from sqlit.shared.ui.screens.error import ErrorScreen
585+
586+
node = self.object_tree.cursor_node
587+
if not node:
588+
return
589+
590+
config = self._get_connection_config_from_node(node)
591+
if not config:
592+
return
593+
594+
if not any(c.name == config.name for c in self.connections):
595+
self.notify("Only saved connections can be starred", severity="warning")
596+
return
597+
598+
previous = config.favorite
599+
config.favorite = not previous
600+
credentials_error: CredentialsPersistError | None = None
601+
602+
try:
603+
self.services.connection_store.save_all(self.connections)
604+
except CredentialsPersistError as exc:
605+
credentials_error = exc
606+
except Exception as exc:
607+
config.favorite = previous
608+
self.notify(f"Failed to update favorite: {exc}", severity="error")
609+
return
610+
611+
if not self.services.connection_store.is_persistent:
612+
self.notify("Connections are not persisted in this session", severity="warning")
613+
614+
self._refresh_connection_tree()
615+
if config.favorite:
616+
self.notify("Connection starred")
617+
else:
618+
self.notify("Connection unstarred")
619+
if credentials_error:
620+
self.push_screen(ErrorScreen("Keyring Error", str(credentials_error)))
621+
582622
def action_delete_connection(self: ConnectionMixinHost) -> None:
583623
from sqlit.shared.ui.screens.confirm import ConfirmScreen
584624

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,8 @@ def _get_config(self) -> ConnectionConfig | None:
656656

657657
config_data["endpoint"] = endpoint
658658
config_data["tunnel"] = tunnel
659+
if self.editing and self.config is not None:
660+
config_data["favorite"] = getattr(self.config, "favorite", False)
659661

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

sqlit/domains/connections/ui/screens/connection_picker/screen.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ class ConnectionPickerScreen(ModalScreen):
5151
BINDINGS = [
5252
Binding("escape", "cancel_or_close_filter", "Cancel"),
5353
Binding("enter", "select", "Select"),
54+
Binding("asterisk", "toggle_star", "Star", show=False),
5455
Binding("s", "save", "Save", show=False),
5556
Binding("n", "new_connection", "New", show=False),
5657
Binding("f", "refresh", "Refresh", show=False),
@@ -538,6 +539,45 @@ def action_save(self) -> None:
538539
self._save_connection_and_refresh(config, option_id)
539540
return
540541

542+
def action_toggle_star(self) -> None:
543+
if self._current_tab == TAB_CLOUD:
544+
return
545+
546+
option = self._get_highlighted_option()
547+
if not option or option.disabled:
548+
return
549+
550+
option_id = str(option.id) if option.id else ""
551+
if not option_id or is_docker_option_id(option_id):
552+
return
553+
554+
config = find_connection_by_name(self.connections, option_id)
555+
if not config:
556+
return
557+
558+
from sqlit.domains.connections.app.credentials import CredentialsPersistError
559+
560+
was_favorite = config.favorite
561+
config.favorite = not was_favorite
562+
try:
563+
self._app().services.connection_store.save_all(self.connections)
564+
except CredentialsPersistError as exc:
565+
self.notify(str(exc), severity="error")
566+
except Exception as exc:
567+
config.favorite = was_favorite
568+
self.notify(f"Failed to update favorite: {exc}", severity="error")
569+
self._update_list()
570+
return
571+
572+
if not self._app().services.connection_store.is_persistent:
573+
self.notify("Connections are not persisted in this session", severity="warning")
574+
575+
if config.favorite:
576+
self.notify("Connection starred")
577+
else:
578+
self.notify("Connection unstarred")
579+
self._update_list()
580+
541581
def _save_cloud_selection(self) -> None:
542582
tree_node = self._get_highlighted_tree_node()
543583
if not tree_node or not tree_node.data:

sqlit/domains/connections/ui/screens/connection_picker/shortcuts.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
find_container_by_id,
2323
is_container_saved,
2424
)
25+
from sqlit.domains.connections.ui.screens.connection_picker.tabs.connections import find_connection_by_name
2526

2627

2728
def build_picker_shortcuts(
@@ -68,6 +69,7 @@ def _build_list_shortcuts(
6869
show_save = False
6970
is_connectable = False
7071
provider_shortcuts: list[tuple[str, str]] = []
72+
star_action: tuple[str, str] | None = None
7173

7274
if option:
7375
option_id = str(option.id) if option.id else ""
@@ -95,14 +97,24 @@ def _build_list_shortcuts(
9597
if current_tab == TAB_CONNECTIONS and option_id:
9698
is_connectable = True
9799

100+
if option_id and not option_id.startswith(DOCKER_PREFIX):
101+
conn = find_connection_by_name(connections, option_id)
102+
if conn:
103+
star_label = "Unstar" if conn.favorite else "Star"
104+
star_action = (star_label, "*")
105+
98106
shortcuts = provider_shortcuts
99107
if not shortcuts:
100108
action_label = "Connect" if is_connectable else "Select"
101109
shortcuts = [(action_label, "enter")]
102110
if show_save:
103111
shortcuts.append(("Save", "s"))
112+
if star_action:
113+
shortcuts.append(star_action)
104114
if current_tab == TAB_CONNECTIONS:
105115
shortcuts.append(("New", "n"))
116+
elif star_action:
117+
shortcuts.append(star_action)
106118

107119
if current_tab in (TAB_DOCKER, TAB_CLOUD):
108120
shortcuts.append(("Refresh", "f"))

sqlit/domains/connections/ui/screens/connection_picker/tabs/connections.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ def build_connections_options(
1515
) -> list[Option]:
1616
options: list[Option] = []
1717

18+
favorite_options: list[Option] = []
1819
saved_options: list[Option] = []
1920
for conn in connections:
2021
matches, indices = fuzzy_match(pattern, conn.name)
@@ -25,14 +26,17 @@ def build_connections_options(
2526
source_prefix = ""
2627
if conn.source == "docker":
2728
source_prefix = "docker "
28-
saved_options.append(
29-
Option(f"{source_prefix}{display} [{db_type}] [dim]({info})[/]", id=conn.name)
30-
)
29+
star = "[yellow]*[/] " if conn.favorite else " "
30+
option = Option(f"{star}{source_prefix}{display} [{db_type}] [dim]({info})[/]", id=conn.name)
31+
if conn.favorite:
32+
favorite_options.append(option)
33+
else:
34+
saved_options.append(option)
3135

3236
options.append(Option("[bold]Saved[/]", id="_header_saved", disabled=True))
3337

34-
if saved_options:
35-
options.extend(saved_options)
38+
if favorite_options or saved_options:
39+
options.extend(favorite_options + saved_options)
3640
else:
3741
options.append(Option("[dim](no saved connections)[/]", id="_empty_saved", disabled=True))
3842

sqlit/domains/connections/ui/screens/connection_picker/tabs/docker.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ def build_docker_options(
9090
) -> list[Option]:
9191
options: list[Option] = []
9292

93+
favorite_options: list[Option] = []
9394
saved_options: list[Option] = []
9495
for conn in connections:
9596
if conn.source != "docker":
@@ -99,9 +100,12 @@ def build_docker_options(
99100
display = highlight_matches(conn.name, indices)
100101
db_type = conn.db_type.upper() if conn.db_type else "DB"
101102
info = get_connection_display_info(conn)
102-
saved_options.append(
103-
Option(f"docker {display} [{db_type}] [dim]({info})[/]", id=conn.name)
104-
)
103+
star = "[yellow]*[/] " if conn.favorite else " "
104+
option = Option(f"{star}docker {display} [{db_type}] [dim]({info})[/]", id=conn.name)
105+
if conn.favorite:
106+
favorite_options.append(option)
107+
else:
108+
saved_options.append(option)
105109

106110
running_options: list[Option] = []
107111
exited_options: list[Option] = []
@@ -141,8 +145,8 @@ def build_docker_options(
141145

142146
options.append(Option("[bold]Saved[/]", id="_header_docker_saved", disabled=True))
143147

144-
if saved_options:
145-
options.extend(saved_options)
148+
if favorite_options or saved_options:
149+
options.extend(favorite_options + saved_options)
146150
else:
147151
options.append(Option("[dim](no saved Docker connections)[/]", id="_empty_docker_saved", disabled=True))
148152

sqlit/domains/explorer/state/tree_on_connection.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ def is_connected_to_this(app: InputContext) -> bool:
3737
self.allows("edit_connection", label="Edit", help="Edit connection")
3838
self.allows("delete_connection", label="Delete", help="Delete connection")
3939
self.allows("duplicate_connection", label="Duplicate", help="Duplicate connection")
40+
self.allows(
41+
"toggle_connection_favorite",
42+
label="Star",
43+
help="Toggle favorite connection",
44+
)
4045

4146
def get_display_bindings(self, app: InputContext) -> tuple[list[DisplayBinding], list[DisplayBinding]]:
4247
left: list[DisplayBinding] = []
@@ -88,6 +93,14 @@ def get_display_bindings(self, app: InputContext) -> tuple[list[DisplayBinding],
8893
)
8994
)
9095
seen.add("duplicate_connection")
96+
left.append(
97+
DisplayBinding(
98+
key=resolve_display_key("toggle_connection_favorite") or "*",
99+
label="Star",
100+
action="toggle_connection_favorite",
101+
)
102+
)
103+
seen.add("toggle_connection_favorite")
91104
left.append(
92105
DisplayBinding(
93106
key=resolve_display_key("delete_connection") or "d",

sqlit/domains/explorer/ui/mixins/tree_labels.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,23 @@ def _format_connection_label(self, conn: Any, status: str, spinner: str | None =
2323
db_type_label = self._db_type_badge(conn.db_type)
2424
escaped_name = escape_markup(conn.name)
2525
source_emoji = conn.get_source_emoji()
26+
favorite_prefix = "[bright_yellow]*[/] " if getattr(conn, "favorite", False) else ""
2627

2728
if status == "connected":
28-
return f"[#4ADE80]* {source_emoji}{escaped_name}[/] [{db_type_label}] ({display_info})"
29+
return (
30+
f"{favorite_prefix}[#4ADE80]• {source_emoji}{escaped_name}[/]"
31+
f" [{db_type_label}] ({display_info})"
32+
)
2933
if status == "connecting":
3034
frame = spinner or SPINNER_FRAMES[0]
3135
return (
32-
f"[#FBBF24]{frame}[/] {source_emoji}{escaped_name} [dim italic]Connecting...[/]"
36+
f"{favorite_prefix}[#FBBF24]{frame}[/] {source_emoji}{escaped_name}"
37+
" [dim italic]Connecting...[/]"
3338
)
34-
return f"{source_emoji}[dim]{escaped_name}[/dim] [{db_type_label}] ({display_info})"
39+
return (
40+
f"{favorite_prefix}{source_emoji}[dim]{escaped_name}[/dim]"
41+
f" [{db_type_label}] ({display_info})"
42+
)
3543

3644
def _connect_spinner_frame(self: TreeMixinHost) -> str:
3745
spinner = getattr(self, "_connect_spinner", None)

0 commit comments

Comments
 (0)