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)?;
|
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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,45 +60,121 @@ 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}
|
||||||
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"
|
query={query}
|
||||||
onDoubleClick={() => handleOpen(query.sql, query.connection_id)}
|
showConnectionTag={effectiveScope === "all"}
|
||||||
>
|
connectionLabel={connectionName(query.connection_id)}
|
||||||
<div className="flex items-center gap-1.5">
|
onOpen={() => handleOpen(query.sql, query.connection_id)}
|
||||||
<Bookmark className="h-3 w-3 shrink-0 text-blue-400" />
|
onDelete={() => deleteMutation.mutate(query.id)}
|
||||||
<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>
|
|
||||||
))}
|
))}
|
||||||
{(!queries || queries.length === 0) && (
|
{visible.length === 0 && (
|
||||||
<div className="py-8 text-center text-xs text-muted-foreground">
|
<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>
|
</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