- Move the Tauri app (React frontend + Rust backend) into desktop/ via git mv — configs unchanged (relative paths: tauri.conf.json, vite.config.ts) - Root package.json becomes a private bun workspace container with orchestration scripts; desktop package renamed gridline-desktop - Scaffold www/ with Astro 7 + Tailwind v4 (hand-wired vite plugin, static output, ready for Dokploy) - Update release.yml for the new layout: projectPath: desktop, desktop/src-tauri resource paths, rust-cache workspace path - Update README + AGENTS.md structure trees and dev commands - Verified: vitest (1074), cargo test (354), desktop build, tauri dev (window launches), tauri build (dmg + app bundles), Astro build
180 lines
7.2 KiB
TypeScript
180 lines
7.2 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from "react";
|
|
import { Clock, Trash2, Download, Play, Star } from "lucide-react";
|
|
import { Tooltip } from "../ui/Tooltip";
|
|
import { useQueryStore } from "../../stores/queryStore";
|
|
|
|
interface QueryHistoryDropdownProps {
|
|
connectionId: string;
|
|
onRestore: (sql: string) => void;
|
|
onRun: (sql: string) => void;
|
|
}
|
|
|
|
export function QueryHistoryDropdown({
|
|
connectionId,
|
|
onRestore,
|
|
onRun,
|
|
}: QueryHistoryDropdownProps) {
|
|
const [open, setOpen] = useState(false);
|
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
|
|
const history = useQueryStore((s) => s.history);
|
|
const loading = useQueryStore((s) => s.historyLoading);
|
|
const historyError = useQueryStore((s) => s.historyError);
|
|
const stale = useQueryStore((s) => s.historyStale);
|
|
const loadHistory = useQueryStore((s) => s.loadHistory);
|
|
const clearHistory = useQueryStore((s) => s.clearHistory);
|
|
const toggleFavorite = useQueryStore((s) => s.toggleFavorite);
|
|
|
|
// Lazy fetch on open
|
|
const handleToggle = useCallback(() => {
|
|
const willOpen = !open;
|
|
setOpen(willOpen);
|
|
if (willOpen && stale) {
|
|
loadHistory(connectionId);
|
|
}
|
|
}, [open, stale, connectionId, loadHistory]);
|
|
|
|
// Close on outside click / Escape
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const onMouseDown = (e: MouseEvent) => {
|
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
|
setOpen(false);
|
|
}
|
|
};
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") setOpen(false);
|
|
};
|
|
document.addEventListener("mousedown", onMouseDown, true);
|
|
document.addEventListener("keydown", onKeyDown);
|
|
return () => {
|
|
document.removeEventListener("mousedown", onMouseDown, true);
|
|
document.removeEventListener("keydown", onKeyDown);
|
|
};
|
|
}, [open]);
|
|
|
|
return (
|
|
<div className="relative" ref={menuRef}>
|
|
<Tooltip content="Query history" side="bottom">
|
|
<button
|
|
type="button"
|
|
onClick={handleToggle}
|
|
aria-label="Query history"
|
|
className="flex items-center rounded px-2 py-1.5 hover:bg-surface-raised hover:text-text transition-colors cursor-pointer text-text-muted"
|
|
>
|
|
<Clock className="h-3.5 w-3.5" />
|
|
</button>
|
|
</Tooltip>
|
|
|
|
{open && (
|
|
<div className="absolute left-0 top-full mt-1 rounded-xl bg-surface border border-border py-1 z-20 w-80 shadow-lg max-h-80 overflow-y-auto">
|
|
{loading && history === null && (
|
|
<div className="flex items-center justify-center gap-2 px-3 py-4 text-center text-sm text-text-muted">
|
|
<span
|
|
data-testid="query-history-spinner"
|
|
aria-hidden="true"
|
|
className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-text-muted/30 border-t-text-muted"
|
|
/>
|
|
Loading...
|
|
</div>
|
|
)}
|
|
{historyError && !loading && (
|
|
<div className="px-3 py-4 text-center text-sm text-red-400">
|
|
{historyError}
|
|
</div>
|
|
)}
|
|
{!loading && !historyError && (!history || history.length === 0) && (
|
|
<div className="px-3 py-4 text-center text-sm text-text-muted">
|
|
No queries yet
|
|
</div>
|
|
)}
|
|
{!loading && !historyError && history && history.length > 0 && (
|
|
<>
|
|
{history.slice(0, 50).map((entry) => (
|
|
<div
|
|
key={entry.id}
|
|
className="group flex items-start gap-2 px-3 py-2 hover:bg-surface-raised border-b border-border/50 last:border-b-0"
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-xs text-text font-mono truncate max-w-[220px]">
|
|
{entry.query_text}
|
|
</div>
|
|
<div className="flex items-center gap-2 mt-0.5 text-[10px] text-text-muted">
|
|
{entry.status === "error" ? (
|
|
<span className="text-red-400">Error</span>
|
|
) : (
|
|
<>
|
|
{entry.execution_time_ms != null && (
|
|
<span>{entry.execution_time_ms}ms</span>
|
|
)}
|
|
{entry.row_count != null && (
|
|
<span>{entry.row_count} rows</span>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
<Tooltip content="Load into editor" side="top">
|
|
<button
|
|
type="button"
|
|
aria-label="Load query into editor"
|
|
onClick={() => {
|
|
setOpen(false);
|
|
onRestore(entry.query_text);
|
|
}}
|
|
className="rounded p-1 text-text-muted hover:text-text hover:bg-surface-raised opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
|
|
>
|
|
<Download className="h-3 w-3" />
|
|
</button>
|
|
</Tooltip>
|
|
<Tooltip content="Run query" side="top">
|
|
<button
|
|
type="button"
|
|
aria-label="Run query from history"
|
|
onClick={() => {
|
|
setOpen(false);
|
|
onRun(entry.query_text);
|
|
}}
|
|
className="rounded p-1 text-text-muted hover:text-text hover:bg-surface-raised opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
|
|
>
|
|
<Play className="h-3 w-3" />
|
|
</button>
|
|
</Tooltip>
|
|
<button
|
|
type="button"
|
|
aria-label={entry.favorite ? "Unfavorite" : "Favorite"}
|
|
onClick={() => toggleFavorite(entry.id, connectionId)}
|
|
className={`rounded p-1 cursor-pointer ${
|
|
entry.favorite
|
|
? "text-amber-400 hover:text-amber-300"
|
|
: "text-text-muted hover:text-amber-400 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
}`}
|
|
>
|
|
<Star
|
|
className="h-3 w-3"
|
|
fill={entry.favorite ? "currentColor" : "none"}
|
|
/>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{/* Clear History footer */}
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setOpen(false);
|
|
clearHistory(connectionId);
|
|
}}
|
|
className="flex items-center gap-1.5 w-full px-3 py-2 text-xs text-text-muted hover:text-red-400 hover:bg-surface-raised transition-colors cursor-pointer border-t border-border"
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
Clear History
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
} |