feat: scope Saved Queries panel to active connection + clean up on delete

Two changes that close the "everything lives in one global pool" gap.

UI: Saved Queries sidebar now defaults to "This connection" and lists
only entries with `connection_id == activeConnectionId` plus unattached
entries (legacy global saves before F2). A small toggle ("All") above
the search box brings the previous behavior back when copying queries
between databases. Each row in "All" mode shows a tag with the source
connection's name; legacy global entries show "unattached".

Backend: delete_connection now best-effort cleans persisted state for
that connection — removes `memory/<id>.md` (delete_memory_core in
memory.rs) and drops every entry in saved_queries.json with the
matching `connection_id` (delete_by_connection_core in
saved_queries.rs). Entries with `connection_id == None` are deliberately
preserved. Cleanup errors are logged but don't block the deletion since
connections.json is the source of truth.

Memory was already per-connection (F1); query history already filters
by connection. This commit makes saved queries behave the same and
stops orphan files from accumulating.
This commit is contained in:
2026-05-07 00:21:27 +03:00
parent 93e526af72
commit 95c9470411
4 changed files with 183 additions and 37 deletions

View File

@@ -118,6 +118,16 @@ pub async fn delete_connection(
fs::write(&path, data)?; fs::write(&path, data)?;
} }
close_connection(&state, &id).await; close_connection(&state, &id).await;
// Best-effort cleanup of per-connection persisted state; errors are logged
// but don't block the deletion (the connections.json is the source of truth).
if let Err(e) = crate::commands::memory::delete_memory_core(&app, &id) {
log::warn!("failed to delete memory file for {}: {}", id, e);
}
if let Err(e) =
crate::commands::saved_queries::delete_by_connection_core(&app, &id).await
{
log::warn!("failed to clean saved queries for {}: {}", id, e);
}
Ok(()) Ok(())
} }

View File

@@ -146,6 +146,21 @@ pub(crate) fn enforce_size_cap(content: &str, cap: usize) -> String {
out out
} }
/// Best-effort delete of a connection's memory file. Returns Ok(()) when the
/// file doesn't exist; only surfaces an error for actual filesystem failures.
/// Used by delete_connection to keep memory/ from filling up with orphan files.
pub(crate) fn delete_memory_core(
app: &AppHandle,
connection_id: &str,
) -> TuskResult<()> {
let path = get_memory_path(app, connection_id)?;
if !path.exists() {
return Ok(());
}
fs::remove_file(&path)?;
Ok(())
}
#[tauri::command] #[tauri::command]
pub async fn get_memory(app: AppHandle, connection_id: String) -> TuskResult<String> { pub async fn get_memory(app: AppHandle, connection_id: String) -> TuskResult<String> {
read_memory_core(&app, &connection_id) read_memory_core(&app, &connection_id)

View File

@@ -54,6 +54,29 @@ pub(crate) async fn save_query_core(app: &AppHandle, query: SavedQuery) -> TuskR
Ok(()) Ok(())
} }
/// Drop every saved-query entry tied to a specific connection_id. Entries with
/// `connection_id == None` (older "global" saves) are deliberately preserved
/// so they remain visible across all connections.
pub(crate) async fn delete_by_connection_core(
app: &AppHandle,
connection_id: &str,
) -> TuskResult<()> {
let path = get_saved_queries_path(app)?;
if !path.exists() {
return Ok(());
}
let data = fs::read_to_string(&path)?;
let mut entries: Vec<SavedQuery> = serde_json::from_str(&data).unwrap_or_default();
let before = entries.len();
entries.retain(|e| e.connection_id.as_deref() != Some(connection_id));
if entries.len() == before {
return Ok(());
}
let data = serde_json::to_string_pretty(&entries)?;
fs::write(&path, data)?;
Ok(())
}
#[tauri::command] #[tauri::command]
pub async fn list_saved_queries( pub async fn list_saved_queries(
app: AppHandle, app: AppHandle,

View File

@@ -1,19 +1,41 @@
import { useState } from "react"; import { useMemo, useState } from "react";
import { useSavedQueries, useDeleteSavedQuery } from "@/hooks/use-saved-queries"; import { useSavedQueries, useDeleteSavedQuery } from "@/hooks/use-saved-queries";
import { useAppStore } from "@/stores/app-store"; import { useAppStore } from "@/stores/app-store";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Search, Trash2, Bookmark } from "lucide-react"; import { Search, Trash2, Bookmark } from "lucide-react";
import type { Tab } from "@/types"; import type { SavedQuery, Tab } from "@/types";
type Scope = "active" | "all";
export function SavedQueriesPanel() { export function SavedQueriesPanel() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const { activeConnectionId, currentDatabase, addTab } = useAppStore(); const [scope, setScope] = useState<Scope>("active");
const { activeConnectionId, currentDatabase, addTab, connections } =
useAppStore();
const { data: queries } = useSavedQueries(search || undefined); const { data: queries } = useSavedQueries(search || undefined);
const deleteMutation = useDeleteSavedQuery(); const deleteMutation = useDeleteSavedQuery();
const handleOpen = (sql: string, connectionId?: string) => { // Effective scope: if no connection is active, "active" has nothing to filter
const cid = activeConnectionId ?? connectionId ?? ""; // against, so we silently broaden to all.
const effectiveScope: Scope = activeConnectionId ? scope : "all";
const visible = useMemo(() => {
if (!queries) return [];
if (effectiveScope === "all") return queries;
return queries.filter(
(q) => !q.connection_id || q.connection_id === activeConnectionId
);
}, [queries, effectiveScope, activeConnectionId]);
const connectionName = (id: string | undefined): string | undefined => {
if (!id) return undefined;
return connections.find((c) => c.id === id)?.name ?? id.slice(0, 8);
};
const handleOpen = (sql: string, queryConnectionId?: string) => {
const cid = activeConnectionId ?? queryConnectionId ?? "";
if (!cid) return; if (!cid) return;
const tab: Tab = { const tab: Tab = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
@@ -28,7 +50,7 @@ export function SavedQueriesPanel() {
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="flex items-center gap-1 p-2"> <div className="flex flex-col gap-1.5 p-2">
<div className="relative flex-1"> <div className="relative flex-1">
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" /> <Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input <Input
@@ -38,26 +60,110 @@ export function SavedQueriesPanel() {
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
</div> </div>
{activeConnectionId && (
<div className="flex gap-1">
<ScopeButton
active={scope === "active"}
onClick={() => setScope("active")}
>
This connection
</ScopeButton>
<ScopeButton
active={scope === "all"}
onClick={() => setScope("all")}
>
All
</ScopeButton>
</div>
)}
</div> </div>
<div className="min-h-0 flex-1 overflow-y-auto"> <div className="min-h-0 flex-1 overflow-y-auto">
{queries?.map((query) => ( {visible.map((query) => (
<div <QueryRow
key={query.id} key={query.id}
query={query}
showConnectionTag={effectiveScope === "all"}
connectionLabel={connectionName(query.connection_id)}
onOpen={() => handleOpen(query.sql, query.connection_id)}
onDelete={() => deleteMutation.mutate(query.id)}
/>
))}
{visible.length === 0 && (
<div className="py-8 text-center text-xs text-muted-foreground">
{effectiveScope === "active"
? "No saved queries for this connection"
: "No saved queries"}
</div>
)}
</div>
</div>
);
}
function ScopeButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
onClick={onClick}
className={`h-6 rounded px-2 text-[11px] transition-colors ${
active
? "bg-accent text-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-accent/50"
}`}
>
{children}
</button>
);
}
function QueryRow({
query,
showConnectionTag,
connectionLabel,
onOpen,
onDelete,
}: {
query: SavedQuery;
showConnectionTag: boolean;
connectionLabel?: string;
onOpen: () => void;
onDelete: () => void;
}) {
const tag = !query.connection_id
? "unattached"
: showConnectionTag
? connectionLabel
: null;
return (
<div
className="group flex w-full flex-col gap-0.5 border-b px-3 py-2 text-left text-xs hover:bg-accent cursor-pointer" className="group flex w-full flex-col gap-0.5 border-b px-3 py-2 text-left text-xs hover:bg-accent cursor-pointer"
onDoubleClick={() => handleOpen(query.sql, query.connection_id)} onDoubleClick={onOpen}
> >
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Bookmark className="h-3 w-3 shrink-0 text-blue-400" /> <Bookmark className="h-3 w-3 shrink-0 text-blue-400" />
<span className="truncate font-medium text-foreground"> <span className="truncate font-medium text-foreground">
{query.name} {query.name}
</span> </span>
{tag && (
<span className="shrink-0 rounded bg-muted/40 px-1 py-px text-[9px] uppercase tracking-wide text-muted-foreground">
{tag}
</span>
)}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="ml-auto h-5 w-5 shrink-0 opacity-0 group-hover:opacity-100 text-destructive hover:text-destructive" className="ml-auto h-5 w-5 shrink-0 opacity-0 group-hover:opacity-100 text-destructive hover:text-destructive"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
deleteMutation.mutate(query.id); onDelete();
}} }}
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
@@ -70,13 +176,5 @@ export function SavedQueriesPanel() {
{new Date(query.created_at).toLocaleDateString()} {new Date(query.created_at).toLocaleDateString()}
</span> </span>
</div> </div>
))}
{(!queries || queries.length === 0) && (
<div className="py-8 text-center text-xs text-muted-foreground">
No saved queries
</div>
)}
</div>
</div>
); );
} }