diff --git a/src/app/(components)/ConnectionConfigScreen.tsx b/src/app/(components)/ConnectionConfigScreen.tsx index 04f2dda4..dea3017b 100644 --- a/src/app/(components)/ConnectionConfigScreen.tsx +++ b/src/app/(components)/ConnectionConfigScreen.tsx @@ -24,6 +24,7 @@ import { import { Textarea } from "@/components/ui/textarea"; import { validateConnectionEndpoint } from "@/lib/validation"; import { appVersion } from "@/env"; +import ErrorMessage from "@/components/custom/ErrorMessage"; interface ConnectionItem { id: string; @@ -173,11 +174,14 @@ export default function ConnectionConfigScreen() { [router] ); - const onConnectClicked = () => { + const [valid, errorMessage] = validateConnectionEndpoint(url); + + const onConnectClicked = useCallback(() => { + if (!valid) return; if (url && token) { connect(url, token); } - }; + }, [valid, connect, url, token]); const onConnectionListChange = () => { setConnections(getConnections()); @@ -256,6 +260,7 @@ export default function ConnectionConfigScreen() { value={url} onChange={(e) => setUrl(e.currentTarget.value)} /> + {url && errorMessage && }
diff --git a/src/app/(components)/MainScreen.tsx b/src/app/(components)/MainScreen.tsx index 51e633ed..960088b7 100644 --- a/src/app/(components)/MainScreen.tsx +++ b/src/app/(components)/MainScreen.tsx @@ -7,8 +7,9 @@ import { TooltipProvider } from "@/components/ui/tooltip"; import { AutoCompleteProvider } from "@/context/AutoCompleteProvider"; import ContextMenuHandler from "./ContentMenuHandler"; import InternalPubSub from "@/lib/internal-pubsub"; -import { useParams, useRouter } from "next/navigation"; -import { Metadata } from "next"; +import { useRouter } from "next/navigation"; +import { normalizeConnectionEndpoint } from "@/lib/validation"; +import { SchemaProvider } from "@/screens/DatabaseScreen/SchemaProvider"; function MainConnection({ credential, @@ -30,7 +31,9 @@ function MainConnection({ return ( - + + + ); } @@ -48,7 +51,11 @@ function InvalidSession() { export default function MainScreen() { const router = useRouter(); const sessionCredential: { url: string; token: string } = useMemo(() => { - return JSON.parse(sessionStorage.getItem("connection") ?? "{}"); + const config = JSON.parse(sessionStorage.getItem("connection") ?? "{}"); + return { + url: normalizeConnectionEndpoint(config.url), + token: config.token, + }; }, []); /** diff --git a/src/app/(components)/SchemaView.tsx b/src/app/(components)/SchemaView.tsx index eae9d72a..8639f49b 100644 --- a/src/app/(components)/SchemaView.tsx +++ b/src/app/(components)/SchemaView.tsx @@ -1,17 +1,15 @@ import { buttonVariants } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { useAutoComplete } from "@/context/AutoCompleteProvider"; -import { useDatabaseDriver } from "@/context/DatabaseDriverProvider"; -import { DatabaseSchemaItem } from "@/drivers/DatabaseDriver"; import { cn } from "@/lib/utils"; import { openTabs } from "@/messages/openTabs"; import { LucideIcon, Table2 } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { OpenContextMenuList, openContextMenuFromEvent, } from "@/messages/openContextMenu"; import OpacityLoading from "./OpacityLoading"; +import { useSchema } from "@/screens/DatabaseScreen/SchemaProvider"; interface SchemaViewItemProps { icon: LucideIcon; @@ -59,26 +57,8 @@ function SchemaViewmItem({ } export default function SchemaView() { - const { updateTableList } = useAutoComplete(); - const [schemaItems, setSchemaItems] = useState([]); - const [loading, setLoading] = useState(false); + const { refresh, schema } = useSchema(); const [selectedIndex, setSelectedIndex] = useState(-1); - const { databaseDriver } = useDatabaseDriver(); - - const fetchSchema = useCallback(() => { - setLoading(true); - - databaseDriver.getTableList().then((tableList) => { - const sortedTableList = [...tableList]; - sortedTableList.sort((a, b) => { - return a.name.localeCompare(b.name); - }); - - setSchemaItems(sortedTableList); - updateTableList(tableList.map((table) => table.name)); - setLoading(false); - }); - }, [databaseDriver, updateTableList]); const prepareContextMenu = useCallback( (tableName?: string) => { @@ -91,24 +71,19 @@ export default function SchemaView() { }, }, { separator: true }, - { title: "Refresh", onClick: fetchSchema }, + { title: "Refresh", onClick: () => refresh() }, ] as OpenContextMenuList; }, - [fetchSchema] + [refresh] ); - useEffect(() => { - fetchSchema(); - }, [fetchSchema]); - return ( - {loading && }
- {schemaItems.map((item, schemaIndex) => { + {schema.map((item, schemaIndex) => { return ( + + +
+ ); +} diff --git a/src/components/custom/ErrorMessage.tsx b/src/components/custom/ErrorMessage.tsx new file mode 100644 index 00000000..43f6520b --- /dev/null +++ b/src/components/custom/ErrorMessage.tsx @@ -0,0 +1,7 @@ +export default function ErrorMessage({ + message, +}: { + readonly message: string; +}) { + return
{message}
; +} diff --git a/src/drivers/DatabaseDriver.tsx b/src/drivers/DatabaseDriver.tsx index c666dfbf..e25e8dc3 100644 --- a/src/drivers/DatabaseDriver.tsx +++ b/src/drivers/DatabaseDriver.tsx @@ -23,8 +23,10 @@ export interface DatabaseTableSchema { export default class DatabaseDriver { protected client: hrana.WsClient; protected stream?: hrana.WsStream; + protected endpoint: string = ""; constructor(url: string, authToken: string) { + this.endpoint = url; this.client = hrana.openWs(url, authToken); } @@ -46,6 +48,10 @@ export default class DatabaseDriver { return this.stream; } + getEndpoint() { + return this.endpoint; + } + async query(stmt: hrana.InStmt) { const stream = this.getStream(); diff --git a/src/lib/validation.test.ts b/src/lib/validation.test.ts index 5d35b8c0..95e39199 100644 --- a/src/lib/validation.test.ts +++ b/src/lib/validation.test.ts @@ -1,4 +1,8 @@ -import { validateConnectionEndpoint, validateOperation } from "./validation"; +import { + normalizeConnectionEndpoint, + validateConnectionEndpoint, + validateOperation, +} from "./validation"; describe("Operation Validation", () => { it("UPDATE with primary key SHOULD be valid operation", () => { @@ -209,4 +213,14 @@ describe("Validate the connection endpoint", () => { false ); }); + + it("Transform libsql:// to wss://", () => { + expect(normalizeConnectionEndpoint("libsql://testing.example.com")).toBe( + "wss://testing.example.com" + ); + + expect(normalizeConnectionEndpoint("wss://testing.example.com")).toBe( + "wss://testing.example.com" + ); + }); }); diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 10a76ce5..2d4253c1 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -73,8 +73,8 @@ export function validateConnectionEndpoint( try { const url = new URL(endpoint); - if (url.protocol !== "wss:") { - return [false, "We only support wss:// at the moment."]; + if (url.protocol !== "wss:" && url.protocol !== "libsql:") { + return [false, "We only support wss:// or libsql:// at the moment."]; } return [true, ""]; @@ -82,3 +82,7 @@ export function validateConnectionEndpoint( return [false, "Your URL is not valid"]; } } + +export function normalizeConnectionEndpoint(endpoint: string) { + return endpoint.replace(/^libsql:\/\//, "wss://"); +} diff --git a/src/screens/DatabaseScreen/ConnectingDialog.tsx b/src/screens/DatabaseScreen/ConnectingDialog.tsx new file mode 100644 index 00000000..91134034 --- /dev/null +++ b/src/screens/DatabaseScreen/ConnectingDialog.tsx @@ -0,0 +1,60 @@ +import { Button } from "@/components/ui/button"; +import { LucideLoader } from "lucide-react"; +import { useRouter } from "next/navigation"; + +export default function ConnectingDialog({ + message, + url, + onRetry, +}: Readonly<{ + loading?: boolean; + url?: string; + message?: string; + onRetry?: () => void; +}>) { + const router = useRouter(); + + let body = ( +
+

+ + Connecting to {url} +

+
+ ); + + if (message) { + body = ( + <> +
+ We have problem connecting to database +
+

+

{message}
+

+
+ + +
+ + ); + } + + return ( +
+
+ LibSQL Studio +
+

LibSQL Studio

+
+
+ + {body} +
+ ); +} diff --git a/src/screens/DatabaseScreen/SchemaProvider.tsx b/src/screens/DatabaseScreen/SchemaProvider.tsx new file mode 100644 index 00000000..e3b055d8 --- /dev/null +++ b/src/screens/DatabaseScreen/SchemaProvider.tsx @@ -0,0 +1,85 @@ +import { useAutoComplete } from "@/context/AutoCompleteProvider"; +import { useDatabaseDriver } from "@/context/DatabaseDriverProvider"; +import { DatabaseSchemaItem } from "@/drivers/DatabaseDriver"; +import { + PropsWithChildren, + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; +import ConnectingDialog from "./ConnectingDialog"; + +const SchemaContext = createContext<{ + schema: DatabaseSchemaItem[]; + refresh: () => void; +}>({ + schema: [], + refresh: () => { + throw new Error("Not implemented"); + }, +}); + +export function useSchema() { + return useContext(SchemaContext); +} + +export function SchemaProvider({ children }: Readonly) { + const { updateTableList } = useAutoComplete(); + const [error, setError] = useState(); + const [schemaItems, setSchemaItems] = useState([]); + const [loading, setLoading] = useState(true); + const { databaseDriver } = useDatabaseDriver(); + + const fetchSchema = useCallback( + (refresh?: boolean) => { + if (refresh) { + setLoading(true); + } + + databaseDriver + .getTableList() + .then((tableList) => { + const sortedTableList = [...tableList]; + sortedTableList.sort((a, b) => { + return a.name.localeCompare(b.name); + }); + + setSchemaItems(sortedTableList); + updateTableList(tableList.map((table) => table.name)); + + setError(undefined); + setLoading(false); + }) + .catch((e) => { + setError(e.message); + setLoading(false); + }); + }, + [databaseDriver, updateTableList, setError] + ); + + useEffect(() => { + fetchSchema(true); + }, [fetchSchema]); + + const props = useMemo(() => { + return { schema: schemaItems, refresh: fetchSchema }; + }, [schemaItems, fetchSchema]); + + if (error || loading) { + return ( + + ); + } + + return ( + {children} + ); +}