Files
gridline/desktop/src/components/editor/QueryHistoryDropdown.tsx
T
adrianbonpin 6854d889c5 refactor: convert to bun-workspaces monorepo (desktop/ + www/)
- 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
2026-08-16 19:31:53 +08:00

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>
);
}