* feat: demo DB seed/regenerate, backup/restore/sync refinements, SSH/SSL polish - Demo SQLite DB: feature-rich seed (12 objects, 500-row audit log) + regenerate action in Settings - Backup/restore: headless-testable core logic, psql -f for plain dumps, sync passes --clean --if-exists - SSH/SSL runtime refinements and connection testing improvements - Data grid: DataTypeIcon component, FK popover, table tree polish - Docs: AGENTS.md/README updates, new screenshots, MIT LICENSE * chore: optimize README screenshots (4.7 MB → 916 KB via pngquant, quality 70-90) * v0.6.0: release workflow, constraint-aware cell editing, pending-cell styling - Bump version to 0.6.0 across package.json, Cargo.toml, tauri.conf.json - Add .github/workflows/release.yml: tag-triggered CI builds macOS (aarch64 + x64), Windows, and Linux installers into a draft GitHub Release - README: installer download table (unsigned note, per-platform files), "how releases are made" section - CellEditor: constraint-aware commit — empty input on nullable columns becomes NULL, NOT NULL text-like types fall back to empty string, all other types blocked with an inline error bubble; replace "Set NULL" checkbox with a NULL row in the FK dropdown / empty enum option - VirtualDataGrid: pending-edit dot → animated pending outline (ring) on staged cells; matching test updates - docs-coverage test: align with rewritten README comparison table
213 lines
6.9 KiB
TypeScript
213 lines
6.9 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { Key, X, ExternalLink, Loader2 } from "lucide-react";
|
|
import * as cmd from "../../lib/commands";
|
|
import type { QueryResult } from "../../lib/types";
|
|
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
|
import { DataTypeIcon } from "../ui/DataTypeIcon";
|
|
|
|
interface FkPreviewPopoverProps {
|
|
connectionId: string;
|
|
schema: string;
|
|
table: string;
|
|
column: string;
|
|
value: string;
|
|
anchorRect: DOMRect | null;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function FkPreviewPopover({
|
|
connectionId,
|
|
schema,
|
|
table,
|
|
column,
|
|
value,
|
|
anchorRect,
|
|
onClose,
|
|
}: FkPreviewPopoverProps) {
|
|
const [data, setData] = useState<QueryResult | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const popoverRef = useRef<HTMLDivElement>(null);
|
|
|
|
const openTab = useDbViewerStore((s) => s.openTab);
|
|
const setColumnFilter = useDbViewerStore((s) => s.setColumnFilter);
|
|
|
|
const handleOpen = () => {
|
|
openTab(schema, table);
|
|
// Find the newly created tab and apply the column filter
|
|
const newTab = useDbViewerStore.getState().tabs.find(
|
|
(t) => t.schema === schema && t.table === table,
|
|
);
|
|
if (newTab) {
|
|
setColumnFilter(newTab.id, column, value);
|
|
}
|
|
onClose();
|
|
};
|
|
|
|
// Fetch the referenced row
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
setError(null);
|
|
cmd
|
|
.getFkPreview(connectionId, schema, table, column, value)
|
|
.then((result) => {
|
|
if (!cancelled) {
|
|
setData(result);
|
|
setLoading(false);
|
|
}
|
|
})
|
|
.catch((e: any) => {
|
|
if (!cancelled) {
|
|
setError(String(e));
|
|
setLoading(false);
|
|
}
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [connectionId, schema, table, column, value]);
|
|
|
|
// Close on Escape
|
|
useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") onClose();
|
|
};
|
|
document.addEventListener("keydown", onKey);
|
|
return () => document.removeEventListener("keydown", onKey);
|
|
}, [onClose]);
|
|
|
|
// Close on outside click
|
|
useEffect(() => {
|
|
const onClick = (e: MouseEvent) => {
|
|
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
|
onClose();
|
|
}
|
|
};
|
|
// Delay to avoid closing immediately from the same click that opened it
|
|
const id = setTimeout(() => document.addEventListener("mousedown", onClick), 0);
|
|
return () => {
|
|
clearTimeout(id);
|
|
document.removeEventListener("mousedown", onClick);
|
|
};
|
|
}, [onClose]);
|
|
|
|
if (!anchorRect) return null;
|
|
|
|
// Compute position to keep popover within viewport
|
|
const popoverWidth = 360;
|
|
const popoverMaxHeight = 320;
|
|
const gap = 8;
|
|
let left = anchorRect.left;
|
|
let top = anchorRect.bottom + gap;
|
|
|
|
// Flip horizontally if off-screen
|
|
if (left + popoverWidth > window.innerWidth - 16) {
|
|
left = Math.max(16, window.innerWidth - popoverWidth - 16);
|
|
}
|
|
// Flip vertically if not enough space below
|
|
if (top + popoverMaxHeight > window.innerHeight - 16) {
|
|
top = anchorRect.top - popoverMaxHeight - gap;
|
|
if (top < 16) top = 16;
|
|
}
|
|
|
|
return createPortal(
|
|
<div
|
|
ref={popoverRef}
|
|
className="fixed z-50 bg-surface border border-border rounded-lg shadow-xl overflow-hidden"
|
|
style={{
|
|
left,
|
|
top,
|
|
width: popoverWidth,
|
|
maxHeight: popoverMaxHeight,
|
|
}}
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-3 py-2 border-b border-border bg-surface/80">
|
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
<Key size={12} className="text-amber-400 shrink-0" />
|
|
<span className="text-xs font-heading text-text truncate">
|
|
{schema}.{table}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={handleOpen}
|
|
className="flex items-center gap-1 px-2 py-0.5 text-[11px] rounded hover:bg-accent/10 text-accent transition-colors cursor-pointer"
|
|
title="Open table in new tab"
|
|
>
|
|
<ExternalLink size={11} />
|
|
<span>Open</span>
|
|
</button>
|
|
<button
|
|
onClick={onClose}
|
|
className="p-0.5 rounded hover:bg-surface-hover text-text-muted hover:text-text transition-colors cursor-pointer"
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="overflow-y-auto" style={{ maxHeight: popoverMaxHeight - 41 }}>
|
|
{loading && (
|
|
<div className="flex items-center justify-center gap-2 py-8 text-sm text-text-muted">
|
|
<Loader2 size={14} className="animate-spin" />
|
|
Loading...
|
|
</div>
|
|
)}
|
|
{error && (
|
|
<div className="flex items-center justify-center py-4 text-xs text-red-500 px-3">
|
|
{error}
|
|
</div>
|
|
)}
|
|
{data && data.rows.length === 0 && !loading && (
|
|
<div className="flex items-center justify-center py-4 text-xs text-text-muted">
|
|
No matching row found
|
|
</div>
|
|
)}
|
|
{data && data.rows.length > 0 && (
|
|
<table className="w-full text-xs" style={{ tableLayout: "fixed" }}>
|
|
<tbody>
|
|
{data.columns.map((col, ci) => {
|
|
const cell = data.rows[0][ci];
|
|
const isNull = cell === null || cell === undefined;
|
|
return (
|
|
<tr
|
|
key={col.name}
|
|
className="border-b border-border last:border-0 hover:bg-surface/30"
|
|
>
|
|
<td
|
|
className="px-3 py-1.5 text-text-muted font-heading whitespace-nowrap overflow-hidden align-top"
|
|
style={{ width: 100, maxWidth: 100 }}
|
|
>
|
|
<div className="flex items-center gap-1 min-w-0">
|
|
{col.is_pk && <Key size={9} className="text-accent shrink-0" />}
|
|
{col.is_fk && <Key size={9} className="text-amber-400 shrink-0" />}
|
|
<span className="truncate">{col.name}</span>
|
|
<DataTypeIcon
|
|
dataType={col.data_type}
|
|
size={9}
|
|
className="text-text-muted/60 shrink-0"
|
|
/>
|
|
</div>
|
|
</td>
|
|
<td className="px-3 py-1.5 text-text align-top break-all">
|
|
{isNull ? (
|
|
<span className="italic text-text-muted">NULL</span>
|
|
) : (
|
|
<span className="break-all">{String(cell)}</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
} |