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:
@@ -118,6 +118,16 @@ pub async fn delete_connection(
|
||||
fs::write(&path, data)?;
|
||||
}
|
||||
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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -146,6 +146,21 @@ pub(crate) fn enforce_size_cap(content: &str, cap: usize) -> String {
|
||||
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]
|
||||
pub async fn get_memory(app: AppHandle, connection_id: String) -> TuskResult<String> {
|
||||
read_memory_core(&app, &connection_id)
|
||||
|
||||
@@ -54,6 +54,29 @@ pub(crate) async fn save_query_core(app: &AppHandle, query: SavedQuery) -> TuskR
|
||||
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]
|
||||
pub async fn list_saved_queries(
|
||||
app: AppHandle,
|
||||
|
||||
@@ -1,19 +1,41 @@
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSavedQueries, useDeleteSavedQuery } from "@/hooks/use-saved-queries";
|
||||
import { useAppStore } from "@/stores/app-store";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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() {
|
||||
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 deleteMutation = useDeleteSavedQuery();
|
||||
|
||||
const handleOpen = (sql: string, connectionId?: string) => {
|
||||
const cid = activeConnectionId ?? connectionId ?? "";
|
||||
// Effective scope: if no connection is active, "active" has nothing to filter
|
||||
// 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;
|
||||
const tab: Tab = {
|
||||
id: crypto.randomUUID(),
|
||||
@@ -28,7 +50,7 @@ export function SavedQueriesPanel() {
|
||||
|
||||
return (
|
||||
<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">
|
||||
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
@@ -38,45 +60,121 @@ export function SavedQueriesPanel() {
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</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 className="min-h-0 flex-1 overflow-y-auto">
|
||||
{queries?.map((query) => (
|
||||
<div
|
||||
{visible.map((query) => (
|
||||
<QueryRow
|
||||
key={query.id}
|
||||
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)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Bookmark className="h-3 w-3 shrink-0 text-blue-400" />
|
||||
<span className="truncate font-medium text-foreground">
|
||||
{query.name}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-auto h-5 w-5 shrink-0 opacity-0 group-hover:opacity-100 text-destructive hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteMutation.mutate(query.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<span className="truncate font-mono text-muted-foreground">
|
||||
{query.sql.length > 80 ? query.sql.slice(0, 80) + "..." : query.sql}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{new Date(query.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
query={query}
|
||||
showConnectionTag={effectiveScope === "all"}
|
||||
connectionLabel={connectionName(query.connection_id)}
|
||||
onOpen={() => handleOpen(query.sql, query.connection_id)}
|
||||
onDelete={() => deleteMutation.mutate(query.id)}
|
||||
/>
|
||||
))}
|
||||
{(!queries || queries.length === 0) && (
|
||||
{visible.length === 0 && (
|
||||
<div className="py-8 text-center text-xs text-muted-foreground">
|
||||
No saved queries
|
||||
{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"
|
||||
onDoubleClick={onOpen}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Bookmark className="h-3 w-3 shrink-0 text-blue-400" />
|
||||
<span className="truncate font-medium text-foreground">
|
||||
{query.name}
|
||||
</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
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-auto h-5 w-5 shrink-0 opacity-0 group-hover:opacity-100 text-destructive hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<span className="truncate font-mono text-muted-foreground">
|
||||
{query.sql.length > 80 ? query.sql.slice(0, 80) + "..." : query.sql}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{new Date(query.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user