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
+56
View File
@@ -24,6 +24,12 @@ import {
getSavedQueries,
updateSavedQuery,
deleteSavedQuery,
setConnectionFavorite,
recordRecentConnection,
getRecentConnections,
clearRecentConnections,
getIndexes,
getConstraints,
} from "./commands";
import type { SchemaGraph } from "./types";
import type { QueryHistoryEntry } from "./commands";
@@ -235,4 +241,54 @@ describe("Query History — v6", () => {
id: "q-id",
});
});
});
describe("v0.5.0 command wrappers", () => {
it("setConnectionFavorite invokes set_connection_favorite with camelCase", async () => {
const mockInvoke = vi.fn().mockResolvedValue(undefined);
vi.mocked(invoke).mockImplementation(mockInvoke);
await setConnectionFavorite("c1", true);
expect(mockInvoke).toHaveBeenCalledWith("set_connection_favorite", { connectionId: "c1", favorite: true });
});
it("recordRecentConnection invokes record_recent_connection", async () => {
const mockInvoke = vi.fn().mockResolvedValue(undefined);
vi.mocked(invoke).mockImplementation(mockInvoke);
await recordRecentConnection("c1");
expect(mockInvoke).toHaveBeenCalledWith("record_recent_connection", { connectionId: "c1" });
});
it("getRecentConnections invokes get_recent_connections with limit", async () => {
const mockInvoke = vi.fn().mockResolvedValue([]);
vi.mocked(invoke).mockImplementation(mockInvoke);
await getRecentConnections(8);
expect(mockInvoke).toHaveBeenCalledWith("get_recent_connections", { limit: 8 });
});
it("clearRecentConnections invokes clear_recent_connections", async () => {
const mockInvoke = vi.fn().mockResolvedValue(undefined);
vi.mocked(invoke).mockImplementation(mockInvoke);
await clearRecentConnections();
expect(mockInvoke).toHaveBeenCalledWith("clear_recent_connections", {});
});
it("getIndexes invokes get_indexes with connectionId + schema", async () => {
const mockInvoke = vi.fn().mockResolvedValue([]);
vi.mocked(invoke).mockImplementation(mockInvoke);
await getIndexes("c1", "public");
expect(mockInvoke).toHaveBeenCalledWith("get_indexes", { connectionId: "c1", schema: "public" });
});
it("getConstraints invokes get_constraints with connectionId + schema", async () => {
const mockInvoke = vi.fn().mockResolvedValue([]);
vi.mocked(invoke).mockImplementation(mockInvoke);
await getConstraints("c1", "public");
expect(mockInvoke).toHaveBeenCalledWith("get_constraints", { connectionId: "c1", schema: "public" });
});
});
+27 -1
View File
@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph } from "./types";
import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph, IndexInfo, ConstraintInfo, RecentConnection } from "./types";
import type { FilterRule, SortRule } from "../stores/dbViewerStore";
import type { ChangePayload } from "./changePayload";
@@ -268,4 +268,30 @@ export async function updateSavedQuery(
export async function deleteSavedQuery(id: string): Promise<void> {
return invoke<void>("delete_saved_query", { id });
}
// ─── v0.5.0: Favorites / Recents / Indexes / Constraints ──────────
export async function setConnectionFavorite(connectionId: string, favorite: boolean): Promise<void> {
return invoke<void>("set_connection_favorite", { connectionId, favorite });
}
export async function recordRecentConnection(connectionId: string): Promise<void> {
return invoke<void>("record_recent_connection", { connectionId });
}
export async function getRecentConnections(limit: number): Promise<RecentConnection[]> {
return invoke<RecentConnection[]>("get_recent_connections", { limit });
}
export async function clearRecentConnections(): Promise<void> {
return invoke<void>("clear_recent_connections", {});
}
export async function getIndexes(connectionId: string, schema?: string): Promise<IndexInfo[]> {
return invoke<IndexInfo[]>("get_indexes", { connectionId, schema });
}
export async function getConstraints(connectionId: string, schema?: string): Promise<ConstraintInfo[]> {
return invoke<ConstraintInfo[]>("get_constraints", { connectionId, schema });
}
+35
View File
@@ -0,0 +1,35 @@
import { describe, it, expect } from "vitest";
// Import the docs as raw strings (vite/client declares `*?raw`); this keeps the
// test free of a `node:fs` dependency so `tsc` (bun run build) stays clean.
import agents from "../../AGENTS.md?raw";
import readme from "../../README.md?raw";
describe("v0.5.0 docs coverage", () => {
it("AGENTS.md marks inline cell editing complete", () => {
expect(agents).toContain("Inline cell editing");
expect(agents).toMatch(/Inline cell editing \| ✅/);
});
it("AGENTS.md marks indexes + constraints complete", () => {
expect(agents).toMatch(/Indexes \(per table\) \| ✅/);
expect(agents).toMatch(/Constraints \(CHECK, UNIQUE beyond PK\/FK\) \| ✅/);
});
it("AGENTS.md marks materialized views complete", () => {
expect(agents).toMatch(/Materialized views \| ✅/);
});
it("AGENTS.md marks stored procedures complete", () => {
expect(agents).toMatch(/Stored procedures \| ✅/);
});
it("AGENTS.md marks favorites + recents + status indicator complete", () => {
expect(agents).toMatch(/Favorites \/ Recent connections \| ✅/);
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 marks inline editing complete (not Upcoming)", () => {
// Gridline's comparison-table cell carries the ✅ marker
expect(readme).toMatch(/Inline cell editing \| ✅ \| ✅ \| \*\*✅/);
expect(readme).not.toMatch(/Inline cell editing.*Upcoming/);
});
});
+2 -2
View File
@@ -3,8 +3,8 @@ import { exportData } from "./exportData";
import type { ColumnInfo } from "./types";
const columns: ColumnInfo[] = [
{ name: "id", data_type: "int", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null },
{ name: "v", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null },
{ name: "id", data_type: "int", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
{ name: "v", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
];
describe("exportData", () => {
+18
View File
@@ -0,0 +1,18 @@
import { describe, it, expect } from "vitest";
import { pruneRecent, dedupeRecent } from "./recentConnections";
import type { RecentConnection } from "./types";
const rc = (id: string, at: string): RecentConnection => ({ connection_id: id, opened_at: at });
describe("recentConnections helpers", () => {
it("pruneRecent keeps the newest N", () => {
const list = [rc("a", "1"), rc("b", "3"), rc("c", "2")];
expect(pruneRecent(list, 2)).toEqual([rc("b", "3"), rc("c", "2")]);
});
it("dedupeRecent moves the latest occurrence of an id to the front", () => {
const list = [rc("a", "1"), rc("b", "2"), rc("a", "3")];
const out = dedupeRecent(list);
expect(out[0].connection_id).toBe("a");
expect(out).toHaveLength(2);
});
});
+19
View File
@@ -0,0 +1,19 @@
import type { RecentConnection } from "./types";
/** Keep the newest N entries (sorted by opened_at DESC). */
export function pruneRecent(list: RecentConnection[], n: number): RecentConnection[] {
return [...list].sort((a, b) => (a.opened_at < b.opened_at ? 1 : -1)).slice(0, n);
}
/** Remove duplicates, keeping the most recent occurrence per connection_id at the front. */
export function dedupeRecent(list: RecentConnection[]): RecentConnection[] {
const seen = new Set<string>();
const out: RecentConnection[] = [];
for (const item of [...list].sort((a, b) => (a.opened_at < b.opened_at ? 1 : -1))) {
if (!seen.has(item.connection_id)) {
seen.add(item.connection_id);
out.push(item);
}
}
return out;
}
+47 -4
View File
@@ -1,4 +1,4 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, expectTypeOf } from "vitest";
import type {
Connection,
ConnectionInput,
@@ -16,6 +16,9 @@ import type {
GraphColumn,
Relationship,
Settings,
IndexInfo,
ConstraintInfo,
RecentConnection,
} from "./types";
describe("ActiveView", () => {
@@ -37,6 +40,7 @@ describe("Connection", () => {
folder_id: null,
keychain_ref: null,
tag_ids: [],
favorite: false,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
// new SSH/SSL fields
@@ -68,6 +72,7 @@ describe("Connection", () => {
folder_id: null,
keychain_ref: null,
tag_ids: [],
favorite: false,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
};
@@ -89,6 +94,7 @@ describe("Connection", () => {
folder_id: null,
keychain_ref: null,
tag_ids: [],
favorite: false,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
};
@@ -184,6 +190,8 @@ describe("ColumnInfo", () => {
is_fk: false,
fk_ref: null,
default_value: null,
editable: true,
is_generated: false,
};
expect(col.name).toBe("id");
expect(col.is_pk).toBe(true);
@@ -198,6 +206,8 @@ describe("ColumnInfo", () => {
is_fk: true,
fk_ref: ["users", "id"],
default_value: null,
editable: true,
is_generated: false,
};
expect(col.fk_ref?.[0]).toBe("users");
});
@@ -206,7 +216,7 @@ describe("ColumnInfo", () => {
describe("QueryResult", () => {
it("is well-typed with columns and rows", () => {
const result: QueryResult = {
columns: [{name:"id",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null},{name:"name",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null},{name:"email",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null}],
columns: [{name:"id",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null,editable:true,is_generated:false},{name:"name",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null,editable:true,is_generated:false},{name:"email",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null,editable:true,is_generated:false}],
rows: [
[1, "Alice"],
[2, "Bob"],
@@ -231,7 +241,7 @@ describe("QueryResult", () => {
it("can have null execution_time", () => {
const result: QueryResult = {
columns: [{name:"id",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null}],
columns: [{name:"id",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null,editable:true,is_generated:false}],
rows: [],
total_rows: 0, page: 1, page_size: 50,
execution_time_ms: null,
@@ -324,7 +334,7 @@ describe("DbViewerTab", () => {
title: "SELECT * FROM users",
query: "SELECT * FROM users",
result: {
columns: [{name:"id",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null},{name:"name",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null}],
columns: [{name:"id",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null,editable:true,is_generated:false},{name:"name",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null,editable:true,is_generated:false}],
rows: [],
total_rows: 0, page: 1, page_size: 50,
},
@@ -452,4 +462,37 @@ describe("Settings", () => {
expect(s.editor_font_size).toBe(13);
expect(s.editor_word_wrap).toBe("off");
});
});
describe("v0.5.0 types", () => {
it("ColumnInfo carries editability metadata", () => {
const c: ColumnInfo = {
name: "id", data_type: "integer", is_nullable: false, is_pk: true,
is_fk: false, fk_ref: null, default_value: null, editable: false, is_generated: false,
};
expectTypeOf(c.editable).toEqualTypeOf<boolean>();
expectTypeOf(c.is_generated).toEqualTypeOf<boolean>();
});
it("IndexInfo has the documented fields", () => {
const i: IndexInfo = {
name: "idx", schema: "public", table: "users", definition: "CREATE INDEX ...",
is_unique: true, method: "btree", columns: ["id"], size_bytes: 4096, tablespace: null,
};
expectTypeOf(i).toMatchTypeOf<IndexInfo>();
});
it("ConstraintInfo has the documented fields", () => {
const c: ConstraintInfo = {
name: "ck", schema: "public", table: "users", contype: "CHECK",
definition: "CHECK (x > 0)", deferrable: false, validated: true, columns: ["x"],
};
expectTypeOf(c).toMatchTypeOf<ConstraintInfo>();
});
it("RecentConnection pairs a connection id with an opened_at timestamp", () => {
const r: RecentConnection = { connection_id: "c1", opened_at: "2026-08-02T00:00:00Z" };
expectTypeOf(r.connection_id).toEqualTypeOf<string>();
});
it("Connection carries favorite", () => {
const c = { favorite: true } as Connection;
expectTypeOf(c.favorite).toEqualTypeOf<boolean>();
});
});
+33 -1
View File
@@ -44,6 +44,8 @@ export interface Connection {
ssl_key_path?: string | null;
// Environment label (production, staging, development, etc.)
environment?: string | null;
// Favorite flag (v0.5.0 — pinned connection)
favorite: boolean;
}
export type NewConnectionMode = "simple" | "detailed";
@@ -131,7 +133,7 @@ export interface ImportResult {
export interface TableInfo {
name: string;
schema: string;
table_type: "TABLE" | "VIEW";
table_type: "TABLE" | "VIEW" | "MATERIALIZED VIEW";
columns?: ColumnInfo[];
}
@@ -143,6 +145,36 @@ export interface ColumnInfo {
is_fk: boolean;
fk_ref: [string, string] | null;
default_value: string | null;
editable: boolean;
is_generated: boolean;
}
export interface IndexInfo {
name: string;
schema: string;
table: string;
definition: string;
is_unique: boolean;
method: string;
columns: string[];
size_bytes: number | null;
tablespace: string | null;
}
export interface ConstraintInfo {
name: string;
schema: string;
table: string;
contype: "CHECK" | "UNIQUE" | "EXCLUSION";
definition: string;
deferrable: boolean;
validated: boolean;
columns: string[];
}
export interface RecentConnection {
connection_id: string;
opened_at: string;
}
export interface QueryResult {
+1
View File
@@ -23,6 +23,7 @@ const makeConnection = (over: Partial<Connection> = {}): Connection => ({
keychain_ref: null,
tag_ids: [],
environment: null,
favorite: false,
created_at: "2026-07-26T00:00:00Z",
updated_at: "2026-07-26T00:00:00Z",
...over,
+8
View File
@@ -0,0 +1,8 @@
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");
});
});