v0.5.0 — Grid Interactivity, Home Polish, Deeper PostgreSQL (#8)

* chore: bump version to 0.5.0 (Task 1)

* feat(store): v7 migration — favorites + recent_connections (Task 2)

* feat(models): add favorite to Connection + Store CRUD (Task 3)

* feat(types): ColumnInfo editability + IndexInfo/ConstraintInfo/RecentConnection (Task 4)

* chore: bump version to 0.5.0 (Task 1) — lockfile

* feat(commands): typed wrappers for favorites/recents/indexes/constraints (Task 5)

* feat(db): PG indexes/constraints queries + matview UNION in tables (Task 6)

* feat(db): get_table_data editability flags + ctid/rowid locator (Task 7)

* feat(db): execute_change no-PK locator guard + affected-count check (Task 8)

* feat(db): preserve bigint precision as string on PG read path (Task 9)

* feat(db): get_indexes / get_constraints commands (Task 10)

* feat(store): favorites + recents Store methods (Task 11)

* feat(commands): favorites/recents IPC + register indexes/constraints (Task 12)

* feat(store): connectionStore favorites/recents/move-selection (Task 13)

* feat(lib): recent-connections pure helpers (Task 14)

* feat(store): dbViewerStore indexes/constraints + stageCellEdit (Task 15)

* feat(grid): pure editability + filter-operator + cell transform (Task 16)

* feat(grid): pure keyboard-nav helper (Task 17)

* feat(grid): CellEditor inline editor (Task 18)

* feat(grid): CellContextMenu + RowDetailDrawer (Task 19)

* feat(grid): focus model + keyboard nav + inline edit + copy + context menu (Task 20)

* feat(db-viewer): FilterBuilder drag-and-drop + type-aware operators (Task 21)

* feat(db-viewer): ObjectExplorer indexes/constraints/procedures + matview icon (Task 22)

* feat(home): ConnectionCard favorite star + on-demand StatusDot (Task 23)

* feat(home): move-to-folder + recents strip + status wiring (Task 24)

* feat(db-viewer): grid wiring + matview read-only + post-commit refetch (Task 25)

* docs: v0.5.0 status + roadmap updates (Task 26)

* polish: empty/error/loading states for v0.5.0 surfaces (Task 28)

* feat(home): duplicateConnection + useConnectionStatus hook, drop StatusDot (FEAT-A)

* feat(home): connection card kebab menu — favorite/test/manage (FEAT-B)

* fix(home): populate server_version/latency_ms in test_connection + clean online display

* style(home): swap grab handle and kebab positions on connection card

* style(home): nudge kebab menu to right-1

* style(home): nudge kebab menu to right-0.5

* feat(home): Escape clears + exits focused search

* feat(grid): context-menu View/Select Row, outside-click close, Esc cancels edit, FK reference (GRID-A)

* feat(grid): smart CellEditor — enum select, FK searchable dropdown, textarea height (GRID-B)

* feat(grid): enums + FK options fed into CellEditor (GRID-C)

* feat(grid): FK dropdown display-column labels + placeholder + empty state

* fix(grid): FK dropdown renders as fixed overlay to avoid clipping

* fix(grid): portal FK dropdown to body + FK reference icon instead of click-to-open

* style(grid): move FK reference icon to the start of the cell

* feat(grid): optimistic staged cell values + pending dot, cleared on refetch

* fix(grid): queue is source of truth for staged values — value diff, Clear All clears dots, same-cell edits replace

* fix(db-viewer): type getLocator for staged-value matching

* test(db-viewer): unit-test deriveStagedValues; fix activeTab null guard

* fix(db-viewer): pass table prop to VirtualDataGrid — staged edits now carry the table name

* test(db-viewer): use index access instead of .at() for TS lib target

* feat(grid): FK dropdown options as one-row column cells (FK-reference style, cap 5)

* style(grid): FK dropdown — values only, fixed 360px width, FK-viewer surface styling

* style(grid): harden FK dropdown minWidth to 360px

* style(grid): cap FK dropdown cells at 3

* style(grid): cap FK dropdown cells at 4

* fix(grid): pending dot clears on commit — values stay until refetch

* fix(db): deserialize pg_attribute char columns as i8 — no more panic on get_table_data

* docs: reflect grid interactivity, smart editors, optimistic queue, FK reference, kebab status
This commit is contained in:
2026-08-03 02:42:24 +08:00
committed by GitHub
parent e0c0db8352
commit 16888460b7
77 changed files with 5671 additions and 265 deletions
+9 -2
View File
@@ -1,10 +1,10 @@
import { describe, it, expect, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ActionRow } from "./ActionRow";
import { useUiStore } from "../../stores/uiStore";
beforeEach(() => useUiStore.setState({ activeView: "home" }));
beforeEach(() => useUiStore.setState({ activeView: "home", selectedItemIds: [] }));
describe("ActionRow", () => {
it("renders Saved Connections title", () => {
@@ -21,4 +21,11 @@ describe("ActionRow", () => {
await userEvent.click(screen.getByText(/settings/i));
expect(useUiStore.getState().activeView).toBe("settings");
});
it("shows a Move to folder button when selection exists and calls onMoveToFolder", async () => {
useUiStore.setState({ selectedItemIds: ["c1"] });
const onMoveToFolder = vi.fn();
render(<ActionRow onMoveToFolder={onMoveToFolder} />);
await userEvent.click(screen.getByRole("button", { name: /move to folder/i }));
expect(onMoveToFolder).toHaveBeenCalled();
});
});
+8 -2
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { Plus, Settings as SettingsIcon, FolderPlus, Trash2, Check, X, ChevronDown } from "lucide-react";
import { Plus, Settings as SettingsIcon, FolderPlus, FolderInput, Trash2, Check, X, ChevronDown } from "lucide-react";
import { Button } from "../ui/Button";
import { useUiStore } from "../../stores/uiStore";
import { ImportExportMenu } from "./ImportExportMenu";
@@ -12,10 +12,11 @@ interface ActionRowProps {
onNewFolder?: () => void;
onFilters?: () => void;
onDeleteSelected?: () => void;
onMoveToFolder?: () => void;
visibleItemIds?: string[];
}
export function ActionRow({ onImport, onExport, onNewFolder, onFilters: _onFilters, onDeleteSelected, visibleItemIds = [] }: ActionRowProps) {
export function ActionRow({ onImport, onExport, onNewFolder, onFilters: _onFilters, onDeleteSelected, onMoveToFolder, visibleItemIds = [] }: ActionRowProps) {
const setActiveView = useUiStore((s) => s.setActiveView);
const openSettings = useUiStore((s) => s.openSettings);
const selectedItemIds = useUiStore((s) => s.selectedItemIds);
@@ -52,6 +53,11 @@ export function ActionRow({ onImport, onExport, onNewFolder, onFilters: _onFilte
<Button variant="ghost" className="text-xs border-0" onClick={onNewFolder ?? (() => {})}>
<FolderPlus size={14} /> New Folder
</Button>
{hasSelection && (
<Button variant="ghost" className="text-xs border-0" onClick={onMoveToFolder}>
<FolderInput size={14} /> Move to folder
</Button>
)}
{hasSelection && (
<div className="relative" ref={menuRef}>
<Button variant="ghost" className="text-xs border-0" onClick={() => setMenuOpen((o) => !o)}>
+82 -2
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { HomeScreen } from "./HomeScreen";
import { useConnectionStore } from "../../stores/connectionStore";
@@ -12,6 +12,8 @@ vi.mock("../../lib/commands", () => ({
getFolders: vi.fn().mockResolvedValue([]),
getTags: vi.fn().mockResolvedValue([]),
getSettings: vi.fn().mockResolvedValue({}),
getRecentConnections: vi.fn().mockResolvedValue([{ connection_id: "recent-1", opened_at: "" }]),
recordRecentConnection: vi.fn().mockResolvedValue(undefined),
deleteConnection: vi.fn().mockResolvedValue(undefined),
deleteConnectionPassword: vi.fn().mockResolvedValue(undefined),
deleteFolder: vi.fn().mockResolvedValue(undefined),
@@ -25,7 +27,7 @@ vi.mock("@tauri-apps/plugin-fs", () => ({
describe("HomeScreen", () => {
beforeEach(() => {
useConnectionStore.setState({ connections: [], folders: [], tags: [], loading: false, error: null });
useConnectionStore.setState({ connections: [], folders: [], tags: [], recent: [], loading: false, error: null });
useUiStore.setState({ searchQuery: "", activeFolderId: null, activeTagIds: [], activeDbTypes: [], activeView: "home", selectedItemIds: [] });
useSettingsStore.setState({ settings: null, loading: false, error: null });
// SearchBar stores its debounce timer on window.__sb; clear any timer leaked
@@ -125,6 +127,83 @@ describe("HomeScreen", () => {
expect(screen.queryByText(/are you sure you want to delete/i)).not.toBeInTheDocument();
expect(screen.queryByTestId("animated-backdrop")).not.toBeInTheDocument();
});
it("records a recent connection when opening a connection", async () => {
const user = userEvent.setup();
useConnectionStore.setState({ connections: [makeConnection("conn-1")] });
render(<HomeScreen />);
await user.click(screen.getByText("Local DB"));
const { recordRecentConnection } = await import("../../lib/commands");
await waitFor(() =>
expect(recordRecentConnection).toHaveBeenCalledWith("conn-1"),
);
});
it("renders the recent connections strip at the root when recents exist", async () => {
useConnectionStore.setState({ connections: [makeConnection("recent-1")] });
useUiStore.setState({ activeFolderId: null, searchQuery: "" });
render(<HomeScreen />);
await waitFor(() => {
expect(screen.getByText("Recent")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /local db/i })).toBeInTheDocument();
});
});
it("opens EditConnectionModal from the connection card kebab menu", async () => {
const user = userEvent.setup();
vi.clearAllMocks();
useConnectionStore.setState({ connections: [makeConnection("conn-1")] });
render(<HomeScreen />);
await user.click(screen.getByLabelText("Connection actions"));
await user.click(screen.getByText("Manage"));
await user.click(screen.getByText("Edit…"));
expect(await screen.findByText("Edit Connection")).toBeInTheDocument();
expect(screen.getByDisplayValue("Local DB")).toBeInTheDocument();
});
it("shows the confirmation dialog before deleting a connection from the kebab menu by default", async () => {
const user = userEvent.setup();
vi.clearAllMocks();
useConnectionStore.setState({ connections: [makeConnection("conn-1")] });
render(<HomeScreen />);
await user.click(screen.getByLabelText("Connection actions"));
await user.click(screen.getByText("Manage"));
await user.click(screen.getByText("Delete…"));
expect(
await screen.findByText(/are you sure you want to delete \"Local DB\"/i),
).toBeInTheDocument();
const { deleteConnection } = await import("../../lib/commands");
expect(deleteConnection).not.toHaveBeenCalled();
});
it("deletes a connection from the kebab menu without confirmation when confirm_before_delete is false", async () => {
const user = userEvent.setup();
vi.clearAllMocks();
useSettingsStore.setState({
settings: {
...baseSettings(),
confirm_before_delete: false,
},
});
useConnectionStore.setState({ connections: [makeConnection("conn-1")] });
render(<HomeScreen />);
await user.click(screen.getByLabelText("Connection actions"));
await user.click(screen.getByText("Manage"));
await user.click(screen.getByText("Delete…"));
const { deleteConnection } = await import("../../lib/commands");
await waitFor(() =>
expect(deleteConnection).toHaveBeenCalledWith("conn-1"),
);
expect(
screen.queryByText(/are you sure you want to delete/i),
).not.toBeInTheDocument();
});
});
function makeConnection(id: string): Connection {
@@ -138,6 +217,7 @@ function makeConnection(id: string): Connection {
folder_id: null,
keychain_ref: null,
tag_ids: [],
favorite: false,
created_at: "",
updated_at: "",
};
+90 -2
View File
@@ -13,10 +13,13 @@ import { ConnectionCard } from "../connections/ConnectionCard";
import { CreateFolderDialog } from "../folders/CreateFolderDialog";
import { EditFolderDialog } from "../folders/EditFolderDialog";
import { ConfirmDialog } from "../ui/ConfirmDialog";
import { EditConnectionModal } from "../db-viewer/EditConnectionModal";
import { MoveToFolderDialog } from "../connections/MoveToFolderDialog";
import { RecentConnectionsStrip } from "../connections/RecentConnectionsStrip";
import { handleImport, handleExport } from "../../lib/importExport";
import { getChildFolders } from "../../lib/utils";
import { useShortcut } from "../../hooks/useShortcut";
import type { Folder } from "../../lib/types";
import type { Connection, Folder } from "../../lib/types";
export function HomeScreen() {
const connections = useFilteredConnections();
@@ -31,15 +34,22 @@ export function HomeScreen() {
const deleteFolder = useConnectionStore((s) => s.deleteFolder);
const deleteConnection = useConnectionStore((s) => s.deleteConnection);
const loadAll = useConnectionStore((s) => s.loadAll);
const moveSelectionToFolder = useConnectionStore((s) => s.moveSelectionToFolder);
const recordRecent = useConnectionStore((s) => s.recordRecent);
const loadRecent = useConnectionStore((s) => s.loadRecent);
const recent = useConnectionStore((s) => s.recent);
const selectedItemIds = useUiStore((s) => s.selectedItemIds);
const clearSelection = useUiStore((s) => s.clearSelection);
const confirmBeforeDelete =
useSettingsStore((s) => s.settings?.confirm_before_delete ?? true);
const [folderDialogOpen, setFolderDialogOpen] = useState(false);
const [editFolder, setEditFolder] = useState<Folder | null>(null);
const [moveToFolderOpen, setMoveToFolderOpen] = useState(false);
const [editingConnection, setEditingConnection] = useState<Connection | null>(null);
const [confirmDelete, setConfirmDelete] = useState<{
type: "folder" | "selected";
type: "folder" | "selected" | "connection";
folder?: Folder;
connection?: Connection;
} | null>(null);
const [activeDragId, setActiveDragId] = useState<string | null>(null);
const searchRef = useRef<SearchBarHandle>(null);
@@ -51,6 +61,7 @@ export function HomeScreen() {
const setActiveConnectionId = useUiStore((s) => s.setActiveConnectionId);
const handleOpenDbViewer = (connectionId: string) => {
recordRecent(connectionId);
setActiveConnectionId(connectionId);
setActiveView("db-viewer");
};
@@ -119,6 +130,11 @@ export function HomeScreen() {
}
}, [folders, activeFolderId, setActiveFolderId]);
// Load recent connections on mount for the root strip
useEffect(() => {
loadRecent();
}, [loadRecent]);
const executeDeleteSelected = async () => {
const folderIds = new Set(folders.map((f) => f.id));
for (const id of selectedItemIds) {
@@ -148,6 +164,24 @@ export function HomeScreen() {
setConfirmDelete(null);
};
const handleDuplicateConnection = async (conn: Connection) => {
try {
await useConnectionStore.getState().duplicateConnection(conn.id);
} catch (e) {
console.error("Failed to duplicate connection:", e);
}
};
const handleDeleteConnection = (conn: Connection) => {
if (confirmBeforeDelete) {
setConfirmDelete({ type: "connection", connection: conn });
} else {
void deleteConnection(conn.id).catch((e) => {
console.error("Failed to delete connection:", e);
});
}
};
return (
<main className="min-h-full p-6 bg-canvas select-none max-w-7xl mx-auto">
<div className="mb-6">
@@ -168,9 +202,13 @@ export function HomeScreen() {
? setConfirmDelete({ type: "selected" })
: executeDeleteSelected()
}
onMoveToFolder={() => setMoveToFolderOpen(true)}
visibleItemIds={visibleItemIds}
/>
</div>
{activeFolderId === null && !searchQuery && (
<RecentConnectionsStrip recents={recent} onOpen={handleOpenDbViewer} />
)}
<DndContext
onDragStart={(event) => setActiveDragId(event.active.id as string)}
onDragEnd={async (event) => {
@@ -194,6 +232,9 @@ export function HomeScreen() {
? setConfirmDelete({ type: "folder", folder: f })
: executeDeleteFolder(f)
}
onEditConnection={setEditingConnection}
onDuplicateConnection={handleDuplicateConnection}
onDeleteConnection={handleDeleteConnection}
/>
<DragOverlay dropAnimation={null}>
{activeDragId && connections.find((c) => c.id === activeDragId) ? (
@@ -242,6 +283,29 @@ export function HomeScreen() {
}}
onClose={() => setEditFolder(null)}
/>
<MoveToFolderDialog
open={moveToFolderOpen}
folders={folders}
selectedCount={selectedItemIds.length}
onConfirm={async (target) => {
try {
await moveSelectionToFolder(selectedItemIds, target);
} catch (e) {
console.error("Failed to move selection:", e);
}
clearSelection();
setMoveToFolderOpen(false);
}}
onClose={() => setMoveToFolderOpen(false)}
/>
{editingConnection && (
<EditConnectionModal
connection={editingConnection}
open
onClose={() => setEditingConnection(null)}
onSaved={() => {}}
/>
)}
{confirmDelete?.type === "selected" && (
<ConfirmDialog
open
@@ -264,6 +328,30 @@ export function HomeScreen() {
onCancel={() => setConfirmDelete(null)}
/>
)}
{confirmDelete?.type === "connection" &&
confirmDelete.connection && (
<ConfirmDialog
open
title="Delete Connection"
message={`Are you sure you want to delete "${confirmDelete.connection.name}"?`}
confirmLabel="Delete"
confirmVariant="ghost"
onConfirm={async () => {
try {
await deleteConnection(
confirmDelete.connection!.id,
);
} catch (e) {
console.error(
"Failed to delete connection:",
e,
);
}
setConfirmDelete(null);
}}
onCancel={() => setConfirmDelete(null)}
/>
)}
</main>
);
}