diff --git a/src/app/(components)/ConnectionConfigScreen.tsx b/src/app/(components)/ConnectionConfigScreen.tsx index 48350d10..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; @@ -69,8 +70,8 @@ function ConnectionEdit({ onComplete }: { onComplete: () => void }) { const onSaveClicked = () => { // Validate the connection - const [isUrlInvalid, urlInvalidMessage] = validateConnectionEndpoint(url); - if (isUrlInvalid) { + const [isValid, urlInvalidMessage] = validateConnectionEndpoint(url); + if (!isValid) { setError(urlInvalidMessage); return; } @@ -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 ead831a7..0d6eab00 100644 --- a/src/app/(components)/MainScreen.tsx +++ b/src/app/(components)/MainScreen.tsx @@ -8,6 +8,8 @@ import { AutoCompleteProvider } from "@/context/AutoCompleteProvider"; import ContextMenuHandler from "./ContentMenuHandler"; import InternalPubSub from "@/lib/internal-pubsub"; import { useRouter } from "next/navigation"; +import { normalizeConnectionEndpoint } from "@/lib/validation"; +import { SchemaProvider } from "@/screens/DatabaseScreen/SchemaProvider"; function MainConnection({ credential, @@ -29,7 +31,9 @@ function MainConnection({ return ( - + + + ); } @@ -47,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)/OptimizeTable/OptimizeTableState.tsx b/src/app/(components)/OptimizeTable/OptimizeTableState.tsx index 5ff37e4b..4825262b 100644 --- a/src/app/(components)/OptimizeTable/OptimizeTableState.tsx +++ b/src/app/(components)/OptimizeTable/OptimizeTableState.tsx @@ -1,5 +1,5 @@ import { selectArrayFromIndexList } from "@/lib/export-helper"; -import { OptimizeTableHeaderProps } from "."; +import { OptimizeTableHeaderProps, TableColumnDataType } from "."; import * as hrana from "@libsql/hrana-client"; import { DatabaseTableSchema } from "@/drivers/DatabaseDriver"; import { LucideKey } from "lucide-react"; @@ -33,11 +33,31 @@ export default class OptimizeTableState { ) { return new OptimizeTableState( dataResult.columnNames.map((headerName, idx) => { + let initialSize = 150; + const dataType = convertSqliteType(dataResult.columnDecltypes[idx]); + + if ( + dataType === TableColumnDataType.INTEGER || + dataType === TableColumnDataType.REAL + ) { + initialSize = 100; + } else if (dataType === TableColumnDataType.TEXT) { + // Use 100 first rows to determine the good initial size + let maxSize = 0; + for (let i = 0; i < Math.min(dataResult.rows.length, 100); i++) { + maxSize = Math.max( + (dataResult.rows[i][headerName ?? ""]?.toString() ?? "").length + ); + } + + initialSize = Math.max(150, Math.min(500, maxSize * 8)); + } + return { - initialSize: 150, + initialSize, name: headerName ?? "", resizable: true, - dataType: convertSqliteType(dataResult.columnDecltypes[idx]), + dataType, icon: schemaResult?.pk.includes(headerName ?? "") ? ( ) : undefined, diff --git a/src/app/(components)/OptimizeTable/TableHeaderResizeHandler.tsx b/src/app/(components)/OptimizeTable/TableHeaderResizeHandler.tsx index 6e8c1f4d..6e434ae7 100644 --- a/src/app/(components)/OptimizeTable/TableHeaderResizeHandler.tsx +++ b/src/app/(components)/OptimizeTable/TableHeaderResizeHandler.tsx @@ -1,5 +1,5 @@ -import { useRef, useState, useEffect } from 'react'; -import styles from './styles.module.css'; +import { useRef, useState, useEffect } from "react"; +import styles from "./styles.module.css"; export default function TableHeaderResizeHandler({ idx, @@ -62,9 +62,9 @@ export default function TableHeaderResizeHandler({ onResize(idx, width); if (table) { - const columns = table.style.gridTemplateColumns.split(' '); - columns[idx] = width + 'px'; - table.style.gridTemplateColumns = columns.join(' '); + const columns = table.style.gridTemplateColumns.split(" "); + columns[idx] = width + "px"; + table.style.gridTemplateColumns = columns.join(" "); } if (edgeResizing) { @@ -76,12 +76,12 @@ export default function TableHeaderResizeHandler({ setResizing(false); }; - document.addEventListener('mousemove', onMouseMove); - document.addEventListener('mouseup', onMouseUp); + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); return () => { - document.removeEventListener('mousemove', onMouseMove); - document.removeEventListener('mouseup', onMouseUp); + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); }; } } 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 3e80c5e6..7403310d 100644 --- a/src/drivers/DatabaseDriver.tsx +++ b/src/drivers/DatabaseDriver.tsx @@ -66,8 +66,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); } @@ -89,6 +91,10 @@ export default class DatabaseDriver { return this.stream; } + getEndpoint() { + return this.endpoint; + } + async query(stmt: hrana.InStmt) { const stream = this.getStream(); @@ -128,17 +134,20 @@ export default class DatabaseDriver { })); // Check auto increment - const seqCount = await this.query( - `SELECT COUNT(*) AS total FROM sqlite_sequence WHERE name=${escapeSqlValue( - tableName - )};` - ); - let hasAutoIncrement = false; - const seqRow = seqCount.rows[0]; - if (seqRow && Number(seqRow[0] ?? 0) > 0) { - hasAutoIncrement = true; - } + + try { + const seqCount = await this.query( + `SELECT COUNT(*) AS total FROM sqlite_sequence WHERE name=${escapeSqlValue( + tableName + )};` + ); + + const seqRow = seqCount.rows[0]; + if (seqRow && Number(seqRow[0] ?? 0) > 0) { + hasAutoIncrement = true; + } + } catch {} return { columns, 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} + ); +}