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