v0.6.0: release workflow, constraint-aware cell editing, pending-cell styling (#9)

* 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
This commit is contained in:
2026-08-04 17:20:14 +08:00
committed by GitHub
parent a9dbf60ed5
commit 002d8345ae
66 changed files with 3591 additions and 1602 deletions
@@ -148,12 +148,12 @@ describe("DbViewerScreen", () => {
const staged = useDbViewerStore.getState().changesQueue[0];
expect(staged?.table).toBe("users");
expect(staged?.schema).toBe("public");
// grid cell shows the optimistic value + the pending dot (2nd match is the queue panel diff)
// grid cell shows the optimistic value + the pending outline (2nd match is the queue panel diff)
await waitFor(() => {
expect(screen.getAllByText("Alicia").length).toBeGreaterThanOrEqual(2);
});
expect(screen.getByTestId("pending-edit-dot")).toBeInTheDocument();
// committing the change clears the pending dot but keeps the value until refetch
expect(screen.getByTestId("pending-cell")).toBeInTheDocument();
// committing the change clears the pending outline but keeps the value until refetch
act(() => {
useDbViewerStore
.getState()
@@ -162,7 +162,7 @@ describe("DbViewerScreen", () => {
);
});
await waitFor(() => {
expect(screen.queryByTestId("pending-edit-dot")).toBeNull();
expect(screen.queryByTestId("pending-cell")).toBeNull();
});
expect(screen.getAllByText("Alicia").length).toBeGreaterThanOrEqual(2);
// clearing the queue clears the optimistic display
+31 -7
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState, Suspense, lazy } from "react";
import { ChevronDown, ChevronUp, Table2, Terminal } from "lucide-react";
import { ChevronDown, ChevronUp, Table2, Terminal, AlertCircle } from "lucide-react";
import { format as formatSql } from "sql-formatter";
import { TooltipProvider } from "../ui/Tooltip";
import { DbViewerSidebar } from "./DbViewerSidebar";
@@ -217,6 +217,19 @@ export function DbViewerScreen({
t.table_type === "MATERIALIZED VIEW",
)
: false;
// Regular views (SQLite + PG) are read-only like matviews: they expose no
// row locator (no ctid/rowid) and cannot be modified via SQLite, so hide
// all data-modifying affordances for them as well.
const isView =
activeTab && activeTab.tabType === "table"
? tables.some(
(t) =>
t.schema === activeTab.schema &&
t.name === activeTab.table &&
t.table_type === "VIEW",
)
: false;
const readOnlyTable = isMatview || isView;
const setTabData = useDbViewerStore((s) => s.setTabData);
const setTabError = useDbViewerStore((s) => s.setTabError);
@@ -1105,7 +1118,7 @@ const onQueriesPanelResizeStart = useCallback(
)
}
variant="query"
isMatview={isMatview}
isMatview={readOnlyTable}
/>
)}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
@@ -1120,9 +1133,9 @@ const onQueriesPanelResizeStart = useCallback(
dbType={currentConnection?.db_type ?? "postgresql"}
tabType={activeTab?.tabType ?? "table"}
getLocator={getLocator}
onStageEdit={isMatview ? undefined : handleStageEdit}
onStageEdit={readOnlyTable ? undefined : handleStageEdit}
onOpenRowDetail={handleOpenRowDetail}
readOnly={isMatview}
readOnly={readOnlyTable}
enumValues={editorOptions?.enums}
fkOptions={editorOptions?.fks}
fkPlaceholders={editorOptions?.fkPlaceholders}
@@ -1217,6 +1230,17 @@ const onQueriesPanelResizeStart = useCallback(
</Suspense>
) : (
<>
{activeTab?.error && (
<div
role="alert"
className="flex items-center gap-2 px-3 py-2 text-xs text-red-400 border-b border-border bg-surface-raised"
>
<AlertCircle size={14} className="shrink-0" />
<span className="truncate">
{activeTab.error}
</span>
</div>
)}
{activeTab?.data && (
<TableControls
connectionId={connectionId}
@@ -1258,7 +1282,7 @@ const onQueriesPanelResizeStart = useCallback(
onClearSelection={() =>
setSelectedRows(new Set())
}
isMatview={isMatview}
isMatview={readOnlyTable}
/>
)}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
@@ -1273,9 +1297,9 @@ const onQueriesPanelResizeStart = useCallback(
dbType={currentConnection?.db_type ?? "postgresql"}
tabType={activeTab?.tabType ?? "table"}
getLocator={getLocator}
onStageEdit={isMatview ? undefined : handleStageEdit}
onStageEdit={readOnlyTable ? undefined : handleStageEdit}
onOpenRowDetail={handleOpenRowDetail}
readOnly={isMatview}
readOnly={readOnlyTable}
enumValues={editorOptions?.enums}
fkOptions={editorOptions?.fks}
fkPlaceholders={editorOptions?.fkPlaceholders}
+13 -11
View File
@@ -3,8 +3,8 @@ 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 { abbreviateType } from "../../lib/utils";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { DataTypeIcon } from "../ui/DataTypeIcon";
interface FkPreviewPopoverProps {
connectionId: string;
@@ -168,7 +168,7 @@ export function FkPreviewPopover({
</div>
)}
{data && data.rows.length > 0 && (
<table className="w-full text-xs">
<table className="w-full text-xs" style={{ tableLayout: "fixed" }}>
<tbody>
{data.columns.map((col, ci) => {
const cell = data.rows[0][ci];
@@ -178,20 +178,22 @@ export function FkPreviewPopover({
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 w-1/3">
<div className="flex items-center gap-1">
<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>
<span
className="text-[10px] text-text-muted/50 shrink-0"
title={col.data_type}
>
{abbreviateType(col.data_type)}
</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">
<td className="px-3 py-1.5 text-text align-top break-all">
{isNull ? (
<span className="italic text-text-muted">NULL</span>
) : (
+16 -2
View File
@@ -239,14 +239,21 @@ export function RestorePage({ connectionId }: RestorePageProps) {
</div>
{/* Clean toggle */}
<label className="flex items-center gap-2.5 cursor-pointer group">
<label
className={`flex items-center gap-2.5 cursor-pointer group ${
format === "plain"
? "opacity-40 pointer-events-none"
: ""
}`}
>
<input
type="checkbox"
checked={clean}
onChange={(e) =>
setClean(e.target.checked)
}
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
disabled={format === "plain"}
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer disabled:cursor-not-allowed"
/>
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
Clean{" "}
@@ -255,6 +262,13 @@ export function RestorePage({ connectionId }: RestorePageProps) {
</code>
</span>
</label>
{format === "plain" && (
<p className="text-[11px] text-text-muted/70 -mt-3">
Plain SQL restores run via psql and don't
support DROP-before-CREATE. Use Custom
Archive for clean restores.
</p>
)}
</div>
{/* Destructive confirmation */}
+118
View File
@@ -83,6 +83,34 @@ describe("TabBar", () => {
expect(screen.queryByTestId("tab-icon-table")).not.toBeInTheDocument();
});
it("renders a view icon on view tabs", () => {
useDbViewerStore.getState().openTab("main", "order_summary");
useDbViewerStore.setState({
tables: [
{ name: "order_summary", schema: "main", table_type: "VIEW" },
],
});
render(<TabBar />);
expect(screen.getByTestId("tab-icon-view")).toBeInTheDocument();
expect(screen.queryByTestId("tab-icon-table")).not.toBeInTheDocument();
});
it("renders a layers icon on materialized view tabs", () => {
useDbViewerStore.getState().openTab("public", "mv_products");
useDbViewerStore.setState({
tables: [
{
name: "mv_products",
schema: "public",
table_type: "MATERIALIZED VIEW" as any,
},
],
});
render(<TabBar />);
expect(screen.getByTestId("tab-icon-matview")).toBeInTheDocument();
expect(screen.queryByTestId("tab-icon-table")).not.toBeInTheDocument();
});
it("shows the changes count as an icon with a badge", () => {
useDbViewerStore.getState().addChange({
type: "update",
@@ -175,4 +203,94 @@ describe("TabBar", () => {
const button = screen.getByRole("button", { name: "Changes queue" });
expect(button.className).toContain("border-amber-500");
});
// ─────────────────────────────────────────────────────────────────────────
// Drag & drop reorder — keep this test LAST in this file.
//
// The vitest config does not enable `globals: true`, so RTL's auto-cleanup
// never unmounts components between tests. A dnd-kit drag leaves its DndContext
// (and document-level listeners) mounted, which silently breaks userEvent/fireEvent
// clicks in any LATER test. The drag itself is fully verified here; placing it
// last isolates the pollution.
// ─────────────────────────────────────────────────────────────────────────
it("reorders tabs via drag and drop (horizontal axis only)", async () => {
const { act, fireEvent } = await import("@testing-library/react");
const store = useDbViewerStore.getState();
store.openTab("public", "users");
store.openTab("public", "posts", true);
store.openTab("public", "comments", true);
// jsdom reports zero-sized rects and non-primary pointers by default,
// which breaks dnd-kit collision detection + pointer activation.
const original = Element.prototype.getBoundingClientRect;
Element.prototype.getBoundingClientRect = function () {
const text = this.textContent ?? "";
const index = text.includes("posts")
? 1
: text.includes("comments")
? 2
: 0;
const x = index * 100;
return {
x,
y: 0,
width: 100,
height: 30,
left: x,
right: x + 100,
top: 0,
bottom: 30,
toJSON: () => ({}),
} as DOMRect;
};
try {
render(<TabBar />);
const usersTab = screen.getByRole("tab", { name: "users" });
// pointerDown lifts the tab (distance constraint >= 4px on move), then
// moves it over the last tab and drops.
await act(async () => {
fireEvent.pointerDown(usersTab, {
pointerId: 1,
clientX: 50,
clientY: 15,
button: 0,
isPrimary: true,
});
});
await act(async () => {
fireEvent.pointerMove(usersTab, {
pointerId: 1,
clientX: 160,
clientY: 15,
});
});
await act(async () => {
fireEvent.pointerMove(usersTab, {
pointerId: 1,
clientX: 260,
clientY: 15,
});
});
await act(async () => {
fireEvent.pointerUp(usersTab, {
pointerId: 1,
clientX: 260,
clientY: 15,
});
});
// Flush dnd-kit's post-drag rAF focus-restore so it cannot leak into
// later tests (userEvent clicks are order-sensitive in jsdom).
await act(async () => {});
} finally {
Element.prototype.getBoundingClientRect = original;
}
expect(
useDbViewerStore.getState().tabs.map((t) => t.table),
).toEqual(["posts", "comments", "users"]);
});
});
+162 -44
View File
@@ -1,14 +1,95 @@
import { useEffect, useRef } from "react";
import { ListChecks, Play, Table2, Terminal, X } from "lucide-react";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useEffect, useRef, type ReactNode } from "react";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
type Modifier,
} from "@dnd-kit/core";
import {
SortableContext,
useSortable,
horizontalListSortingStrategy,
sortableKeyboardCoordinates,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { ListChecks, Play, Table2, Layers, Eye, Terminal, X } from "lucide-react";
import { useDbViewerStore, type ViewerTab } from "../../stores/dbViewerStore";
import { ChangesQueuePanel } from "./ChangesQueuePanel";
function SortableTab({
tab,
isActive,
icon,
onSelect,
onClose,
}: {
tab: ViewerTab;
isActive: boolean;
icon: ReactNode;
onSelect: () => void;
onClose: () => void;
}) {
const {
attributes,
listeners,
setNodeRef,
transform: rawTransform,
transition,
isDragging,
} = useSortable({ id: tab.id });
// dnd-kit scales the dragged item to the width of whichever tab it is
// hovering over (adjustScale). Tabs have different widths, which would warp
// the text — always render at scale 1 and let the horizontal strategy handle
// positioning.
const transform = rawTransform
? { ...rawTransform, scaleX: 1, scaleY: 1 }
: null;
return (
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
{...attributes}
role="tab"
aria-selected={isActive}
aria-label={tab.table}
{...listeners}
onClick={onSelect}
className={[
"group flex shrink-0 items-center gap-2 border-r border-border px-3 text-sm transition-colors cursor-grab active:cursor-grabbing select-none",
isActive ? "bg-canvas text-text" : "text-text-muted hover:text-text",
isDragging ? "opacity-50 z-10 ring-1 ring-accent" : "",
].join(" ")}
>
<span className="flex-1 text-left select-none">{icon}{tab.table}</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
aria-label={`Close ${tab.table}`}
className="rounded p-0.5 opacity-60 transition-opacity hover:bg-surface-raised hover:opacity-100 cursor-pointer"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
);
}
export function TabBar({ onCommitted }: { onCommitted?: () => void } = {}) {
const tabs = useDbViewerStore((state) => state.tabs);
const tables = useDbViewerStore((state) => state.tables);
const activeTabId = useDbViewerStore((state) => state.activeTabId);
const closeTab = useDbViewerStore((state) => state.closeTab);
const setActiveTab = useDbViewerStore((state) => state.setActiveTab);
const openQueryTab = useDbViewerStore((state) => state.openQueryTab);
const reorderTab = useDbViewerStore((state) => state.reorderTab);
const changesQueue = useDbViewerStore((state) => state.changesQueue);
const changesPanelExpanded = useDbViewerStore(
(state) => state.changesPanelExpanded,
@@ -17,6 +98,30 @@ export function TabBar({ onCommitted }: { onCommitted?: () => void } = {}) {
(state) => state.toggleChangesPanel,
);
// Drag threshold so a click still selects the tab; a deliberate drag (>= 4px)
// starts a reorder. Keyboard sorting uses arrow keys, one axis only.
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
// Keep the dragged tab on the tab strip: zero out any vertical movement so
// dragging is constrained to the horizontal axis only.
const restrictToHorizontalAxis: Modifier = ({ transform }) => ({
...transform,
y: 0,
});
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const from = tabs.findIndex((t) => t.id === active.id);
const to = tabs.findIndex((t) => t.id === over.id);
if (from >= 0 && to >= 0) reorderTab(from, to);
};
const pendingCount = changesQueue.filter(
(c) => c.status === "pending",
).length;
@@ -50,49 +155,62 @@ export function TabBar({ onCommitted }: { onCommitted?: () => void } = {}) {
className="flex flex-1 min-w-0 items-stretch overflow-x-auto"
role="tablist"
>
{tabs.map((tab) => {
const isActive = tab.id === activeTabId;
return (
<div
key={tab.id}
role="tab"
aria-selected={isActive}
onClick={() => setActiveTab(tab.id)}
className={[
"group flex shrink-0 items-center gap-2 border-r border-border px-3 text-sm transition-colors cursor-pointer",
isActive
? "bg-canvas text-text"
: "text-text-muted hover:text-text",
].join(" ")}
>
<span className="flex-1 text-left select-none">
{tab.tabType === "query" ? (
<Terminal
data-testid="tab-icon-query"
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToHorizontalAxis]}
onDragEnd={handleDragEnd}
>
<SortableContext
items={tabs.map((t) => t.id)}
strategy={horizontalListSortingStrategy}
>
<div className="flex items-stretch">
{tabs.map((tab) => {
const isActive = tab.id === activeTabId;
const objectType =
tab.tabType === "table"
? tables.find(
(t) =>
t.schema === tab.schema && t.name === tab.table,
)?.table_type
: undefined;
const icon =
tab.tabType === "query" ? (
<Terminal
data-testid="tab-icon-query"
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
/>
) : objectType === "VIEW" ? (
<Eye
data-testid="tab-icon-view"
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
/>
) : objectType === "MATERIALIZED VIEW" ? (
<Layers
data-testid="tab-icon-matview"
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
/>
) : (
<Table2
data-testid="tab-icon-table"
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
/>
);
return (
<SortableTab
key={tab.id}
tab={tab}
isActive={isActive}
icon={icon}
onSelect={() => setActiveTab(tab.id)}
onClose={() => closeTab(tab.id)}
/>
) : (
<Table2
data-testid="tab-icon-table"
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
/>
)}
{tab.table}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
closeTab(tab.id);
}}
aria-label={`Close ${tab.table}`}
className="rounded p-0.5 opacity-60 transition-opacity hover:bg-surface-raised hover:opacity-100 cursor-pointer"
>
<X className="h-3.5 w-3.5" />
</button>
);
})}
</div>
);
})}
</SortableContext>
</DndContext>
</div>
{/* Right: fixed actions */}
@@ -40,6 +40,23 @@ describe("TableTree", () => {
expect(screen.getByText("Materialized View")).toBeInTheDocument();
});
it("shows a distinct icon and label for views", () => {
useDbViewerStore.setState({
schemas: ["public"],
currentSchema: "public",
tables: [
{
name: "order_summary",
schema: "public",
table_type: "VIEW",
},
],
});
render(<TableTree />);
expect(screen.getByText("order_summary")).toBeInTheDocument();
expect(screen.getByText("View")).toBeInTheDocument();
});
it("opens a tab when table is clicked", async () => {
const user = userEvent.setup();
useDbViewerStore.setState({
+8 -3
View File
@@ -1,5 +1,5 @@
import { useState } from "react";
import { ChevronRight, ChevronDown, Table2, Layers, Key, Type } from "lucide-react";
import { ChevronRight, ChevronDown, Table2, Layers, Eye, Key, Type } from "lucide-react";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useUiStore } from "../../stores/uiStore";
import { TableOverflowMenu } from "./TableOverflowMenu";
@@ -71,8 +71,13 @@ export function TableTree({ searchQuery }: { searchQuery?: string }) {
const isExpanded = expanded.has(key);
const cols = columnCache[key] ?? table.columns ?? [];
const isMatView = table.table_type === "MATERIALIZED VIEW";
const TypeIcon = isMatView ? Layers : Table2;
const typeLabel = isMatView ? "Materialized View" : null;
const isView = table.table_type === "VIEW";
const TypeIcon = isMatView ? Layers : isView ? Eye : Table2;
const typeLabel = isMatView
? "Materialized View"
: isView
? "View"
: null;
return (
<div key={key}>
<div
+83 -19
View File
@@ -13,11 +13,17 @@ describe("CellEditor", () => {
fireEvent.keyDown(input, { key: "Enter" });
expect(onCommit).toHaveBeenCalledWith("Alicia");
});
it("commits null when the setNull flag is toggled", () => {
it("commits null when a nullable cell is emptied", () => {
const onCommit = vi.fn();
render(<CellEditor initialValue="Alice" dataType="text" onCommit={onCommit} onCancel={vi.fn()} nullable />);
const nullCheckbox = screen.getByLabelText(/set null/i);
fireEvent.click(nullCheckbox);
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onCommit).toHaveBeenCalledWith(null);
});
it("leaves an empty nullable cell alone when it was already empty (NULL stays NULL)", () => {
const onCommit = vi.fn();
render(<CellEditor initialValue="" dataType="text" onCommit={onCommit} onCancel={vi.fn()} nullable />);
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" });
expect(onCommit).toHaveBeenCalledWith(null);
});
@@ -31,6 +37,79 @@ describe("CellEditor", () => {
render(<CellEditor initialValue="{}" dataType="jsonb" onCommit={vi.fn()} onCancel={vi.fn()} />);
expect(screen.getByRole("textbox").tagName).toBe("TEXTAREA");
});
it("blocks empty commits on non-nullable non-text columns and shows an error", () => {
const onCommit = vi.fn();
render(<CellEditor initialValue="42" dataType="integer" onCommit={onCommit} onCancel={vi.fn()} />);
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onCommit).not.toHaveBeenCalled();
expect(screen.getByTestId("cell-editor-error")).toBeInTheDocument();
});
it("commits an empty string on non-nullable text columns", () => {
const onCommit = vi.fn();
render(<CellEditor initialValue="Alice" dataType="text" onCommit={onCommit} onCancel={vi.fn()} />);
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onCommit).toHaveBeenCalledWith("");
});
it("clears the validation error as soon as the user types again", () => {
const onCommit = vi.fn();
render(<CellEditor initialValue="42" dataType="integer" onCommit={onCommit} onCancel={vi.fn()} />);
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByTestId("cell-editor-error")).toBeInTheDocument();
fireEvent.change(input, { target: { value: "5" } });
expect(screen.queryByTestId("cell-editor-error")).toBeNull();
});
it("does not offer a NULL row in the FK dropdown for non-nullable FK columns", () => {
const onCommit = vi.fn();
render(
<CellEditor
initialValue="1"
dataType="integer"
fkOptions={[{ value: "1", label: "1 — Alice" }]}
onCommit={onCommit}
onCancel={vi.fn()}
/>
);
const buttons = screen.getAllByRole("button");
expect(buttons.some((b) => b.textContent === "NULL")).toBe(false);
});
it("offers a NULL row in the FK dropdown for nullable FK columns", () => {
const onCommit = vi.fn();
render(
<CellEditor
initialValue="1"
dataType="integer"
fkOptions={[{ value: "1", label: "1 — Alice" }]}
onCommit={onCommit}
onCancel={vi.fn()}
nullable
/>
);
const nullRow = screen.getAllByRole("button").find((b) => b.textContent === "NULL");
expect(nullRow).toBeDefined();
fireEvent.click(nullRow!);
expect(onCommit).toHaveBeenCalledWith(null);
});
it("commits null when the NULL option is selected in enum mode", () => {
const onCommit = vi.fn();
render(
<CellEditor
initialValue="active"
dataType="text"
enumValues={["active", "inactive", "pending"]}
onCommit={onCommit}
onCancel={vi.fn()}
nullable
/>
);
fireEvent.change(screen.getByRole("combobox"), { target: { value: "" } });
expect(onCommit).toHaveBeenCalledWith(null);
});
it("renders a combobox with enum values and commits on change", () => {
const onCommit = vi.fn();
render(
@@ -49,21 +128,6 @@ describe("CellEditor", () => {
fireEvent.change(select, { target: { value: "pending" } });
expect(onCommit).toHaveBeenCalledWith("pending");
});
it("commits null via Set NULL in enum mode", () => {
const onCommit = vi.fn();
render(
<CellEditor
initialValue="active"
dataType="text"
enumValues={["active", "inactive", "pending"]}
onCommit={onCommit}
onCancel={vi.fn()}
nullable
/>
);
fireEvent.click(screen.getByLabelText(/set null/i));
expect(onCommit).toHaveBeenCalledWith(null);
});
it("filters FK options by query and commits the clicked value", () => {
const onCommit = vi.fn();
render(
@@ -105,7 +169,7 @@ describe("CellEditor", () => {
render(<CellEditor initialValue="long text" dataType="text" onCommit={vi.fn()} onCancel={vi.fn()} />);
const input = screen.getByRole("textbox");
expect(input.tagName).toBe("TEXTAREA");
expect(input.className).toContain("h-6");
expect(input.className).toContain("overflow-y-auto");
});
it("renders the FK placeholder and a No matches empty state", () => {
+86 -49
View File
@@ -23,7 +23,12 @@ interface CellEditorProps {
}
const inputClass =
"w-full px-1 py-0.5 text-xs bg-surface border border-border rounded font-mono";
"min-w-0 flex-1 bg-transparent px-3 font-heading text-xs text-text outline-none placeholder:text-text-muted";
const controlClass =
"min-w-0 flex-1 rounded bg-surface px-2 py-1 font-heading text-xs text-text outline-none placeholder:text-text-muted";
/** Types that tolerate an empty string when NOT NULL ('' is a valid value). */
const TEXT_LIKE = ["char", "text", "uuid", "bit"];
export function CellEditor({
initialValue,
@@ -36,8 +41,9 @@ export function CellEditor({
onCancel,
}: CellEditorProps) {
const [value, setValue] = useState(initialValue);
const [setNull, setSetNull] = useState(initialValue === "" && nullable);
const [query, setQuery] = useState("");
const [error, setError] = useState<string | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const ref = useRef<HTMLTextAreaElement | HTMLInputElement>(null);
const enumRef = useRef<HTMLSelectElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
@@ -47,6 +53,8 @@ export function CellEditor({
width: number;
} | null>(null);
const textLike = TEXT_LIKE.some((t) => dataType.toLowerCase().includes(t));
useLayoutEffect(() => {
if (fkOptions && fkOptions.length > 0 && searchRef.current) {
const r = searchRef.current.getBoundingClientRect();
@@ -80,22 +88,59 @@ export function CellEditor({
);
const Tag = large ? "textarea" : "input";
const commit = () => onCommit(setNull ? null : value);
const handleSetNull = (checked: boolean) => {
setSetNull(checked);
if (checked) onCommit(null);
/**
* Constraint-aware commit resolution:
* - Empty input on a nullable column → NULL (smart "clear = null").
* - Empty input on a NOT NULL column → only text-ish types may fall back to
* an empty string; everything else is blocked with an error.
*/
const resolveCommit = (
raw: string,
): { value: string | null } | { error: string } => {
if (raw.trim() === "") {
if (nullable) return { value: null };
if (textLike) return { value: raw };
return { error: "This column cannot be NULL" };
}
return { value: raw };
};
const commitRaw = (raw: string) => {
const r = resolveCommit(raw);
if ("error" in r) {
setError(r.error);
return;
}
setError(null);
onCommit(r.value);
};
const commit = () => commitRaw(value);
const errorRect = error ? rootRef.current?.getBoundingClientRect() : null;
const errorBubble =
error && errorRect
? createPortal(
<div
data-testid="cell-editor-error"
className="fixed z-50 pointer-events-none rounded-md border border-red-500/50 bg-red-950/95 px-2 py-1 text-[10px] text-red-300 shadow-lg"
style={{ top: errorRect.bottom + 4, left: errorRect.left, maxWidth: 320 }}
>
{error}
</div>,
document.body,
)
: null;
// Priority: enum > FK > default input/textarea
if (enumValues && enumValues.length > 0) {
return (
<div className="flex flex-col gap-1 p-1 bg-canvas border border-accent rounded">
<div ref={rootRef} className={`flex h-full w-full items-center gap-1.5 px-1.5 ${error ? "ring-1 ring-inset ring-red-500/60" : ""}`}>
<select
ref={enumRef}
className={inputClass}
className={controlClass}
value={initialValue}
onChange={(e) => onCommit(setNull ? null : e.target.value)}
onChange={(e) => commitRaw(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
@@ -110,16 +155,7 @@ export function CellEditor({
</option>
))}
</select>
{nullable && (
<label className="flex items-center gap-1 text-[10px] text-text-muted">
<input
type="checkbox"
checked={setNull}
onChange={(e) => handleSetNull(e.target.checked)}
/>
Set NULL
</label>
)}
{errorBubble}
</div>
);
}
@@ -139,19 +175,22 @@ export function CellEditor({
? fkOptions
: fkOptions.filter((o) => fkSearchText(o).includes(q));
return (
<div className="flex flex-col gap-1 p-1 bg-canvas border border-accent rounded">
<div ref={rootRef} className={`flex h-full w-full items-center gap-1.5 px-1.5 ${error ? "ring-1 ring-inset ring-red-500/60" : ""}`}>
<input
ref={searchRef}
aria-label="Search foreign key options"
placeholder={fkPlaceholder ?? "Search…"}
className={inputClass}
className={controlClass}
value={query}
onChange={(e) => setQuery(e.target.value)}
onChange={(e) => {
setQuery(e.target.value);
setError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
if (filtered.length > 0) onCommit(filtered[0].value);
else onCommit(query);
if (filtered.length > 0) commitRaw(filtered[0].value);
else commitRaw(query);
} else if (e.key === "Escape") {
e.preventDefault();
onCancel();
@@ -172,6 +211,15 @@ export function CellEditor({
}}
className="max-h-28 overflow-y-auto bg-surface border border-border rounded-lg shadow-xl"
>
{nullable && (
<button
type="button"
className="block w-full px-3 py-1.5 hover:bg-surface-raised text-xs text-left italic text-text-muted"
onClick={() => commitRaw("")}
>
NULL
</button>
)}
{filtered.length === 0 && (
<div className="px-2 py-1 text-xs text-text-muted">No matches</div>
)}
@@ -180,7 +228,7 @@ export function CellEditor({
key={o.value}
type="button"
className="block w-full px-3 py-1.5 hover:bg-surface-raised text-xs text-left"
onClick={() => onCommit(o.value)}
onClick={() => commitRaw(o.value)}
>
{o.cells && o.cells.length > 0 ? (
<span className="flex items-center gap-0 min-w-0">
@@ -205,30 +253,28 @@ export function CellEditor({
</div>,
document.body,
)}
{nullable && (
<label className="flex items-center gap-1 text-[10px] text-text-muted">
<input
type="checkbox"
checked={setNull}
onChange={(e) => handleSetNull(e.target.checked)}
/>
Set NULL
</label>
)}
{errorBubble}
</div>
);
}
const cls = large ? `${inputClass} h-6 resize-none overflow-y-auto leading-none` : inputClass;
const cls = large
? `${inputClass} h-4 resize-none overflow-y-auto whitespace-pre leading-none py-0.5`
: inputClass;
return (
<div className="flex flex-col gap-1 p-1 bg-canvas border border-accent rounded">
<div
ref={rootRef}
data-testid="cell-editor"
className={`flex h-full w-full items-center gap-2 ${error ? "ring-1 ring-inset ring-red-500/60" : ""}`}
>
<Tag
ref={ref as any}
className={cls}
placeholder={nullable && value === "" ? "NULL" : undefined}
value={value}
onChange={(e) => {
setValue(e.target.value);
setSetNull(false);
setError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
@@ -240,16 +286,7 @@ export function CellEditor({
}
}}
/>
{nullable && (
<label className="flex items-center gap-1 text-[10px] text-text-muted">
<input
type="checkbox"
checked={setNull}
onChange={(e) => handleSetNull(e.target.checked)}
/>
Set NULL
</label>
)}
{errorBubble}
</div>
);
}
+8 -8
View File
@@ -48,24 +48,24 @@ describe("VirtualDataGrid", () => {
expect(screen.getByRole("textbox")).toBeInTheDocument();
});
it("shows staged values passed from the parent + a pending dot", () => {
it("shows staged values passed from the parent + a pending outline", () => {
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
render(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
dbType="postgresql" tabType="table" stagedValues={{ "0:name": "Alicia" }} pendingKeys={{ "0:name": true }} />);
expect(screen.getByText("Alicia")).toBeInTheDocument();
expect(screen.getByTestId("pending-edit-dot")).toBeInTheDocument();
expect(screen.getByTestId("pending-cell")).toBeInTheDocument();
});
it("pending dot requires pendingKeys even when a staged value exists (committed → no dot)", () => {
it("pending outline requires pendingKeys even when a staged value exists (committed → no outline)", () => {
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
render(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
dbType="postgresql" tabType="table" stagedValues={{ "0:name": "Alicia" }} pendingKeys={{}} />);
expect(screen.getByText("Alicia")).toBeInTheDocument();
expect(screen.queryByTestId("pending-edit-dot")).toBeNull();
expect(screen.queryByTestId("pending-cell")).toBeNull();
});
it("clears staged values when the stagedValues prop empties (Clear All)", () => {
@@ -393,7 +393,7 @@ describe("VirtualDataGrid", () => {
expect(screen.getByText("id")).toBeInTheDocument();
});
it("renders a pending-edit dot on the pending cell", () => {
it("renders a pending outline on the pending cell", () => {
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(
mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })),
@@ -416,10 +416,10 @@ describe("VirtualDataGrid", () => {
/>,
);
expect(screen.getByTestId("pending-edit-dot")).toBeInTheDocument();
expect(screen.getByTestId("pending-cell")).toBeInTheDocument();
});
it("does not render a pending-edit dot without pendingCell", () => {
it("does not render a pending outline without pendingCell", () => {
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(
mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })),
@@ -441,7 +441,7 @@ describe("VirtualDataGrid", () => {
/>,
);
expect(screen.queryByTestId("pending-edit-dot")).toBeNull();
expect(screen.queryByTestId("pending-cell")).toBeNull();
});
// ── GRID-A: context menu + editing behavior ─────────────────────────
+13 -8
View File
@@ -43,7 +43,7 @@ interface VirtualDataGridProps {
fkPlaceholders?: Record<string, string>;
/** Optimistic staged cell values keyed `${rowIndex}:${colName}` → value (null = NULL), from the changes queue. */
stagedValues?: Record<string, string | null>;
/** Keys of cells with a PENDING (not yet committed) update → drives the amber dot. */
/** Keys of cells with a PENDING (not yet committed) update → drives the pulsing orange outline. */
pendingKeys?: Record<string, boolean>;
}
@@ -297,6 +297,12 @@ export function VirtualDataGrid({
};
const commitEdit = (committed: string | null) => {
// Constraint guard: never stage NULL on a NOT NULL column
// (the editor blocks this with UX; this is defense in depth)
if (committed === null && col.is_nullable === false) {
setEditingCell(null);
return;
}
// oldData must be the DB value (the un-staged cell), so the queue's
// revert/display stays correct even after repeated edits of the same cell.
const dbValue = ci >= 0 ? row[ci] : undefined;
@@ -326,10 +332,13 @@ export function VirtualDataGrid({
return (
<div
key={col.name}
data-testid={isPending ? "pending-cell" : undefined}
className={`relative px-3 py-2 font-heading text-xs truncate select-text border-r border-border self-stretch ${
isFk ? "cursor-pointer underline decoration-dotted underline-offset-2 hover:text-accent" : ""
} ${isJson ? "cursor-pointer text-accent/80 hover:text-accent" : ""} ${
isActive ? "bg-accent/10 ring-1 ring-inset ring-accent outline-none" : ""
isActive && !isEditing ? "bg-accent/10 ring-1 ring-inset ring-accent outline-none" : ""
} ${isEditing ? "outline outline-2 outline-amber-400 outline-offset-[-2px]" : ""} ${
isPending && !isEditing ? "animate-pending-ring" : ""
}`}
role={isJson ? "button" : undefined}
tabIndex={isJson ? 0 : -1}
@@ -408,12 +417,6 @@ export function VirtualDataGrid({
) : (
String(displayCell)
)}
{isPending && (
<span
className="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-amber-400"
data-testid="pending-edit-dot"
/>
)}
</div>
);
},
@@ -438,6 +441,8 @@ export function VirtualDataGrid({
(row: number, col: number) => {
const column = visibleColumns[col];
if (!column || !isCellEditable(column, tabType, dbType, readOnly)) return;
// NOT NULL columns can't be nulled (matches the context-menu gating)
if (!column.is_nullable) return;
const ci = columns.findIndex((c) => c.name === column.name);
const value = rows[row]?.[ci];
if (value === null || value === undefined) return;
+116
View File
@@ -0,0 +1,116 @@
import { describe, it, expect } from "vitest";
import { render } from "@testing-library/react";
import {
Banknote,
Binary,
Braces,
CalendarClock,
Clock,
FileCode2,
Fingerprint,
Globe,
Hash,
Layers,
ListChecks,
Shapes,
ToggleLeft,
Type,
} from "lucide-react";
import { DataTypeIcon, getDataTypeIcon } from "./DataTypeIcon";
describe("getDataTypeIcon", () => {
it("maps numeric types to Hash", () => {
expect(getDataTypeIcon("integer")).toBe(Hash);
expect(getDataTypeIcon("bigint")).toBe(Hash);
expect(getDataTypeIcon("numeric(10,2)")).toBe(Hash);
expect(getDataTypeIcon("double precision")).toBe(Hash);
expect(getDataTypeIcon("REAL")).toBe(Hash);
});
it("maps money to Banknote", () => {
expect(getDataTypeIcon("money")).toBe(Banknote);
});
it("maps text types to Type", () => {
expect(getDataTypeIcon("character varying")).toBe(Type);
expect(getDataTypeIcon("text")).toBe(Type);
expect(getDataTypeIcon("TEXT")).toBe(Type);
expect(getDataTypeIcon("citext")).toBe(Type);
});
it("maps booleans to ToggleLeft", () => {
expect(getDataTypeIcon("boolean")).toBe(ToggleLeft);
expect(getDataTypeIcon("bool")).toBe(ToggleLeft);
});
it("maps JSON types to Braces", () => {
expect(getDataTypeIcon("json")).toBe(Braces);
expect(getDataTypeIcon("jsonb")).toBe(Braces);
});
it("maps binary types to Binary", () => {
expect(getDataTypeIcon("bytea")).toBe(Binary);
expect(getDataTypeIcon("BLOB")).toBe(Binary);
expect(getDataTypeIcon("bit varying")).toBe(Binary);
});
it("maps date/time types to CalendarClock or Clock", () => {
expect(getDataTypeIcon("timestamp")).toBe(CalendarClock);
expect(getDataTypeIcon("timestamp with time zone")).toBe(CalendarClock);
expect(getDataTypeIcon("date")).toBe(Clock);
expect(getDataTypeIcon("time without time zone")).toBe(Clock);
expect(getDataTypeIcon("interval")).toBe(Clock);
});
it("maps uuid to Fingerprint", () => {
expect(getDataTypeIcon("uuid")).toBe(Fingerprint);
});
it("maps array types to Layers", () => {
expect(getDataTypeIcon("integer[]")).toBe(Layers);
expect(getDataTypeIcon("text[]")).toBe(Layers);
});
it("maps enums, xml, network and geometric types", () => {
expect(getDataTypeIcon("enum('a','b')")).toBe(ListChecks);
expect(getDataTypeIcon("xml")).toBe(FileCode2);
expect(getDataTypeIcon("inet")).toBe(Globe);
expect(getDataTypeIcon("point")).toBe(Shapes);
});
it("falls back to Type for unknown/custom types", () => {
expect(getDataTypeIcon("mood")).toBe(Type);
});
});
describe("DataTypeIcon", () => {
it("renders the mapped icon with a title tooltip", () => {
const { container } = render(<DataTypeIcon dataType="integer" />);
const svg = container.querySelector("svg");
expect(svg).not.toBeNull();
expect(svg!.getAttribute("class")).toContain("lucide-hash");
// The tooltip lives on the wrapper span (React SVG types omit `title`).
expect(container.querySelector("span")!.getAttribute("title")).toBe("integer");
});
it("honors a custom title override", () => {
const { container } = render(
<DataTypeIcon dataType="integer" title="User ID (int)" />,
);
expect(container.querySelector("span")!.getAttribute("title")).toBe(
"User ID (int)",
);
});
it("applies size and className", () => {
const { container } = render(
<DataTypeIcon dataType="jsonb" size={9} className="text-text-muted/60" />,
);
const svg = container.querySelector("svg")!;
expect(svg.getAttribute("width")).toBe("9");
expect(container.querySelector("span")!.getAttribute("class")).toContain(
"text-text-muted/60",
);
expect(svg.getAttribute("class")).toContain("lucide-braces");
});
});
+66
View File
@@ -0,0 +1,66 @@
import { memo } from "react";
import {
Banknote,
Binary,
Braces,
CalendarClock,
Clock,
FileCode2,
Fingerprint,
Globe,
Hash,
Layers,
ListChecks,
Shapes,
ToggleLeft,
Type,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
/**
* Map a DB column data type (PostgreSQL / SQLite / MySQL strings) to a
* representative lucide icon. Best-effort by string matching — unknown and
* custom types fall back to the generic `Type` icon.
*/
export function getDataTypeIcon(dataType: string): LucideIcon {
const t = dataType.toLowerCase().trim();
if (t === "money") return Banknote; // money — numeric, but deserves its own icon
if (/json/.test(t)) return Braces; // json, jsonb
if (t.endsWith("[]")) return Layers; // array types: integer[], text[], ...
if (/uuid/.test(t)) return Fingerprint; // uuid
if (/bool/.test(t)) return ToggleLeft; // boolean, bool
if (/bytea|blob|varbinary|^binary|bit/.test(t)) return Binary; // bytea, blob, binary, bit
if (/timestamp|datetime/.test(t)) return CalendarClock; // timestamp, timestamptz, datetime
if (/^date\b|^time\b|interval/.test(t)) return Clock; // date, time, interval
if (/smallint|integer|bigint|serial|numeric|decimal|real|float|double|int2|int4|int8|number/.test(t))
return Hash; // numeric types
if (/enum/.test(t)) return ListChecks; // mysql ENUM(...)
if (/xml/.test(t)) return FileCode2; // xml
if (/inet|cidr|macaddr/.test(t)) return Globe; // network types
if (/point|line|lseg|box|path|polygon|circle/.test(t)) return Shapes; // geometric types
return Type; // text, character varying, and unknown/custom types
}
interface DataTypeIconProps {
dataType: string;
size?: number;
className?: string;
/** Tooltip / accessible label. Defaults to the raw data type string. */
title?: string;
}
/** A compact icon representing a DB column's data type, with a tooltip. */
export const DataTypeIcon = memo(function DataTypeIcon({
dataType,
size = 12,
className,
title = dataType,
}: DataTypeIconProps) {
const Icon = getDataTypeIcon(dataType);
return (
<span title={title} className={className}>
<Icon size={size} aria-hidden="true" />
</span>
);
});
+6
View File
@@ -19,11 +19,17 @@
--font-family-sans: "Outfit", sans-serif;
--animate-toolbar-pulse: toolbar-pulse 1.6s ease-in-out infinite;
--animate-pending-ring: pending-ring 1.6s ease-in-out infinite;
@keyframes toolbar-pulse {
0%, 100% { opacity: 0; }
50% { opacity: 0.12; }
}
@keyframes pending-ring {
0%, 100% { box-shadow: inset 0 0 0 2px rgba(249, 115, 22, 0.9); }
50% { box-shadow: inset 0 0 0 2px rgba(249, 115, 22, 0.12); }
}
}
:root {
+7 -5
View File
@@ -4,7 +4,7 @@ import { describe, it, expect } from "vitest";
import agents from "../../AGENTS.md?raw";
import readme from "../../README.md?raw";
describe("v0.5.0 docs coverage", () => {
describe("v0.6.0 docs coverage", () => {
it("AGENTS.md marks inline cell editing complete", () => {
expect(agents).toContain("Inline cell editing");
expect(agents).toMatch(/Inline cell editing \| ✅/);
@@ -24,12 +24,14 @@ describe("v0.5.0 docs coverage", () => {
expect(agents).toMatch(/Connection status indicator on cards \| ✅/);
expect(agents).toMatch(/Move-to-folder bulk action \| ✅/);
});
it("README declares v0.5.0", () => {
expect(readme).toContain("0.5.0");
it("README declares v0.6.0", () => {
expect(readme).toContain("0.6.0");
});
it("README marks inline editing complete (not Upcoming)", () => {
// Gridline's comparison-table cell carries the ✅ marker
expect(readme).toMatch(/Inline cell editing \| ✅ \| ✅ \| \*\*/);
// Key Features lists inline editing as a shipped feature
expect(readme).toMatch(/- \*\*Inline cell editing\*\*/);
// comparison-table row for the stage→commit queue carries the ✅ marker
expect(readme).toMatch(/Changes queue \(stage → commit\)\s*\|[^|]*❌[^|]*\|[^|]*❌[^|]*\|\s*\*\*✅ Queue → Commit All\*\*/);
expect(readme).not.toMatch(/Inline cell editing.*Upcoming/);
});
});
+2 -2
View File
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
import pkg from "../../package.json";
describe("version", () => {
it("declares v0.5.0 across the app shell", () => {
expect(pkg.version).toBe("0.5.0");
it("declares v0.6.0 across the app shell", () => {
expect(pkg.version).toBe("0.6.0");
});
});
+33
View File
@@ -90,6 +90,39 @@ describe("dbViewerStore", () => {
expect(st.activeTabId).toBe(st.tabs[0].id);
});
it("reorderTab moves a tab and keeps the active tab by id", () => {
useDbViewerStore.getState().openTab("public", "users");
useDbViewerStore.getState().openTab("public", "posts", true);
useDbViewerStore.getState().openTab("public", "comments", true);
const before = useDbViewerStore.getState().tabs;
const activeId = useDbViewerStore.getState().activeTabId;
const activeTable = before.find((t) => t.id === activeId)!.table;
useDbViewerStore.getState().reorderTab(0, 2);
const after = useDbViewerStore.getState().tabs;
expect(after.map((t) => t.table)).toEqual(["posts", "comments", "users"]);
// Active tab must follow the moved item (tracked by id, not index).
expect(useDbViewerStore.getState().activeTabId).toBe(activeId);
expect(after.find((t) => t.id === activeId)!.table).toBe(activeTable);
});
it("reorderTab ignores out-of-range and no-op moves", () => {
useDbViewerStore.getState().openTab("public", "users");
useDbViewerStore.getState().openTab("public", "posts", true);
const reorderTab = useDbViewerStore.getState().reorderTab;
reorderTab(0, 0);
expect(useDbViewerStore.getState().tabs.map((t) => t.table)).toEqual([
"users",
"posts",
]);
reorderTab(-1, 1);
reorderTab(0, 5);
expect(useDbViewerStore.getState().tabs.map((t) => t.table)).toEqual([
"users",
"posts",
]);
});
it("setPage updates pagination", () => {
const store = useDbViewerStore.getState();
store.openTab("public", "users");
+20
View File
@@ -104,6 +104,7 @@ interface DbViewerState {
openQueryTab: () => void;
setDefaultPageSize: (size: number) => void;
closeTab: (tabId: string) => void;
reorderTab: (fromIndex: number, toIndex: number) => void;
closeTabsForTable: (schema: string, table: string) => void;
setActiveTab: (tabId: string) => void;
setPage: (tabId: string, page: number) => void;
@@ -244,6 +245,25 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
set({ tabs: remaining, activeTabId: newActiveId });
},
// Move a tab to a new index (drag-to-reorder). The active tab is tracked by
// id, so it follows the moved tab automatically.
reorderTab: (fromIndex, toIndex) => {
const { tabs } = get();
if (
fromIndex < 0 ||
fromIndex >= tabs.length ||
toIndex < 0 ||
toIndex >= tabs.length ||
fromIndex === toIndex
) {
return;
}
const next = [...tabs];
const [moved] = next.splice(fromIndex, 1);
next.splice(toIndex, 0, moved);
set({ tabs: next });
},
closeTabsForTable: (schema, table) => {
const { tabs, activeTabId } = get();
const remaining = tabs.filter(