Fixes #41. MySQL columns now decode by their declared type instead of a blind try_get chain, so every non-string type (integers, DECIMAL, BIT, YEAR, DATE/TIME/DATETIME/TIMESTAMP) renders its value instead of NULL. MySQL restore runs in-process over the sqlx driver (no bundled MariaDB client, so MySQL 8 caching_sha2_password works) and the Clean toggle is implemented. .sql files can be opened with Gridline from the OS file manager. Verified: cargo test --lib 442 passed / 0 failed; bun run test 133 files / 1151 tests exit 0; tsc clean; both live MySQL 8 integration tests pass with the bundled CLI hidden.
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
|
||||
const listeners: Record<string, (e: unknown) => void> = {};
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: vi.fn(async (name: string, cb: (e: unknown) => void) => {
|
||||
listeners[name] = cb;
|
||||
return () => {};
|
||||
}),
|
||||
}));
|
||||
|
||||
const takePendingSqlFiles = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../lib/commands", () => ({
|
||||
takePendingSqlFiles: () => takePendingSqlFiles(),
|
||||
}));
|
||||
|
||||
import { useSqlFileOpen } from "./useSqlFileOpen";
|
||||
import { useUiStore } from "../stores/uiStore";
|
||||
import { useDbViewerStore } from "../stores/dbViewerStore";
|
||||
import { useNotificationStore } from "../stores/notificationStore";
|
||||
|
||||
const file = { path: "/tmp/student_db.sql", name: "student_db.sql", content: "SELECT 1;" };
|
||||
|
||||
beforeEach(() => {
|
||||
takePendingSqlFiles.mockReset();
|
||||
useUiStore.setState({ activeConnectionId: null, pendingSqlFile: null, activeView: "home" });
|
||||
useDbViewerStore.setState({ tabs: [], activeTabId: null });
|
||||
});
|
||||
|
||||
describe("useSqlFileOpen", () => {
|
||||
it("opens a tab when a connection is active", async () => {
|
||||
takePendingSqlFiles.mockResolvedValue([file]);
|
||||
useUiStore.setState({ activeConnectionId: "conn-1" });
|
||||
|
||||
renderHook(() => useSqlFileOpen());
|
||||
|
||||
await waitFor(() => {
|
||||
const tab = useDbViewerStore.getState().tabs.slice(-1)[0];
|
||||
expect(tab?.query).toBe("SELECT 1;");
|
||||
});
|
||||
expect(useDbViewerStore.getState().tabs.slice(-1)[0]?.table).toBe("student_db.sql");
|
||||
expect(useUiStore.getState().pendingSqlFile).toBeNull();
|
||||
});
|
||||
|
||||
it("stashes the file and prompts when no connection is active", async () => {
|
||||
takePendingSqlFiles.mockResolvedValue([file]);
|
||||
|
||||
renderHook(() => useSqlFileOpen());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useUiStore.getState().pendingSqlFile).toEqual(file);
|
||||
});
|
||||
expect(useDbViewerStore.getState().tabs).toHaveLength(0);
|
||||
expect(useUiStore.getState().activeView).toBe("home");
|
||||
});
|
||||
|
||||
it("drains the buffer again when the nudge event fires", async () => {
|
||||
takePendingSqlFiles.mockResolvedValue([]);
|
||||
useUiStore.setState({ activeConnectionId: "conn-1" });
|
||||
|
||||
renderHook(() => useSqlFileOpen());
|
||||
await waitFor(() => expect(takePendingSqlFiles).toHaveBeenCalledTimes(1));
|
||||
|
||||
takePendingSqlFiles.mockResolvedValue([file]);
|
||||
listeners["sql-file-opened"]?.({});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useDbViewerStore.getState().tabs.slice(-1)[0]?.query).toBe("SELECT 1;");
|
||||
});
|
||||
});
|
||||
|
||||
it("opens the stashed file once a connection becomes active", async () => {
|
||||
takePendingSqlFiles.mockResolvedValue([file]);
|
||||
renderHook(() => useSqlFileOpen());
|
||||
|
||||
await waitFor(() => expect(useUiStore.getState().pendingSqlFile).toEqual(file));
|
||||
|
||||
useUiStore.setState({ activeConnectionId: "conn-2" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useDbViewerStore.getState().tabs.slice(-1)[0]?.query).toBe("SELECT 1;");
|
||||
});
|
||||
expect(useUiStore.getState().pendingSqlFile).toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces a read failure as a notification instead of throwing", async () => {
|
||||
takePendingSqlFiles.mockRejectedValue(new Error("a.sql is not valid UTF-8"));
|
||||
useUiStore.setState({ activeConnectionId: "conn-1" });
|
||||
|
||||
renderHook(() => useSqlFileOpen());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useNotificationStore.getState().notifications.slice(-1)[0]?.message).toContain("UTF-8");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useEffect } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { takePendingSqlFiles } from "../lib/commands";
|
||||
import { useUiStore } from "../stores/uiStore";
|
||||
import { useDbViewerStore } from "../stores/dbViewerStore";
|
||||
import { useNotificationStore } from "../stores/notificationStore";
|
||||
import type { PendingSqlFile } from "../lib/types";
|
||||
|
||||
/**
|
||||
* Turn OS "open this .sql file with Gridline" requests into editor tabs.
|
||||
*
|
||||
* Every platform path (macOS RunEvent::Opened, Windows/Linux argv, the
|
||||
* single-instance forward) ends up in a Rust buffer; this hook only ever
|
||||
* *drains* that buffer. That is deliberate: draining is idempotent, so a cold
|
||||
* start, a warm start, and a webview reload all converge on the same code path
|
||||
* and there is no cold-start race to handle.
|
||||
*
|
||||
* Opening never executes: the script lands in a query tab and runs only when
|
||||
* the user presses Run, which passes through the destructive-query guard.
|
||||
*/
|
||||
export function useSqlFileOpen(): void {
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
const deliver = async () => {
|
||||
let files: PendingSqlFile[];
|
||||
try {
|
||||
files = await takePendingSqlFiles();
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
useNotificationStore.getState().notify(`Could not open SQL file: ${msg}`, "error");
|
||||
return;
|
||||
}
|
||||
if (disposed) return;
|
||||
for (const file of files) openOrStash(file);
|
||||
};
|
||||
|
||||
void deliver();
|
||||
void listen("sql-file-opened", () => {
|
||||
void deliver();
|
||||
})
|
||||
.then((un) => {
|
||||
unlisten = un;
|
||||
})
|
||||
.catch(() => {
|
||||
// No Tauri event bridge (browser / tests): `listen` rejects there. The
|
||||
// mount-time drain already ran, and the Rust buffer survives until a
|
||||
// real webview asks for it, so there is nothing to recover here. The
|
||||
// drain is idempotent, so a missed nudge is not a correctness problem —
|
||||
// a warm webview still drains on mount and on the next event.
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
unlisten?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// A stashed file has no target until the user picks a connection, so
|
||||
// deliver it as soon as one becomes active.
|
||||
const pending = useUiStore((s) => s.pendingSqlFile);
|
||||
const activeConnectionId = useUiStore((s) => s.activeConnectionId);
|
||||
useEffect(() => {
|
||||
if (pending && activeConnectionId) {
|
||||
openQueryTabFor(pending);
|
||||
useUiStore.getState().clearPendingSqlFile();
|
||||
}
|
||||
}, [pending, activeConnectionId]);
|
||||
}
|
||||
|
||||
function openOrStash(file: PendingSqlFile): void {
|
||||
const { activeConnectionId } = useUiStore.getState();
|
||||
if (!activeConnectionId) {
|
||||
// Guide rather than error: a Finder double-click should not dead-end.
|
||||
useUiStore.getState().setPendingSqlFile(file);
|
||||
useUiStore.getState().setActiveView("home");
|
||||
useNotificationStore
|
||||
.getState()
|
||||
.notify(`${file.name} is ready — open a connection to use it.`, "info");
|
||||
return;
|
||||
}
|
||||
openQueryTabFor(file);
|
||||
}
|
||||
|
||||
function openQueryTabFor(file: PendingSqlFile): void {
|
||||
useDbViewerStore.getState().openQueryTab({
|
||||
sql: file.content,
|
||||
label: file.name,
|
||||
filePath: file.path,
|
||||
});
|
||||
useUiStore.getState().setActiveView("db-viewer");
|
||||
}
|
||||
Reference in New Issue
Block a user