diff --git a/.prettierrc.json b/.prettierrc.json index cdd1fdc..ca688e7 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -1,5 +1,5 @@ { - "printWidth": 120, + "printWidth": 80, "semi": false, "singleQuote": true, "tabWidth": 2, @@ -8,5 +8,8 @@ "importOrder": ["^@core/(.*)$", "^@server/(.*)$", "^@ui/(.*)$", "^[./]"], "importOrderSeparation": true, "importOrderSortSpecifiers": true, - "plugins": ["@trivago/prettier-plugin-sort-imports", "prettier-plugin-tailwindcss"] + "plugins": [ + "@trivago/prettier-plugin-sort-imports", + "prettier-plugin-tailwindcss" + ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 85de7e1..ad0a9a6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,7 @@ { "editor.formatOnSave": true, "editor.wordWrap": "wordWrapColumn", - "editor.wordWrapColumn": 120, + "editor.wordWrapColumn": 80, "files.associations": { "*.css": "tailwindcss" }, diff --git a/forge.config.ts b/forge.config.ts index 3cfd260..8d3c63a 100644 --- a/forge.config.ts +++ b/forge.config.ts @@ -12,7 +12,12 @@ const config: ForgeConfig = { asar: true, }, rebuildConfig: {}, - makers: [new MakerSquirrel({}), new MakerZIP({}, ['darwin']), new MakerRpm({}), new MakerDeb({})], + makers: [ + new MakerSquirrel({}), + new MakerZIP({}, ['darwin']), + new MakerRpm({}), + new MakerDeb({}), + ], plugins: [ new VitePlugin({ // `build` can specify multiple entry builds, which can be Main process, Preload scripts, Worker process, etc. diff --git a/index.html b/index.html index 8ef2253..4495dea 100644 --- a/index.html +++ b/index.html @@ -5,7 +5,10 @@ Hello World! -
+
diff --git a/interface.d.ts b/interface.d.ts new file mode 100644 index 0000000..7e3f911 --- /dev/null +++ b/interface.d.ts @@ -0,0 +1,16 @@ +export interface IElectronAPI { + startServer: () => void + stopServer: () => void + restartServer: () => void + getDatabases: () => void + createDatabase: (name: string) => Promise + removeDatabase: (name: string) => void + onSetReady: (callback: (value: boolean) => void) => Electron.IpcRenderer + onSetDatabases: (callback: (value: string[]) => void) => Electron.IpcRenderer +} + +declare global { + interface Window { + electronAPI: IElectronAPI + } +} diff --git a/package-lock.json b/package-lock.json index 4a0411b..13db6f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,15 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@electric-sql/pglite": "^0.2.5", + "@formkit/auto-animate": "^0.8.2", + "clsx": "^2.1.1", "electron-squirrel-startup": "^1.0.1", + "lucide-react": "^0.439.0", + "pg-gateway": "^0.3.0-alpha.6", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "tailwind-merge": "^2.5.2" }, "devDependencies": { "@electron-forge/cli": "^7.4.0", @@ -39,6 +45,7 @@ "tailwindcss": "^3.4.10", "ts-node": "^10.9.2", "typescript": "~4.5.4", + "unplugin-fonts": "^1.1.1", "vite": "^5.4.3" } }, @@ -483,6 +490,12 @@ "node": ">=12" } }, + "node_modules/@electric-sql/pglite": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.2.5.tgz", + "integrity": "sha512-LrMX2kX0mCVN4xkhIDv1KBVukWtoOI/+P9MDQgHX5QEeZCi5S60LZOa0VWXjufPEz7mJtbuXWJRujD++t0gsHA==", + "license": "Apache-2.0" + }, "node_modules/@electron-forge/cli": { "version": "7.4.0", "resolved": "https://registry.npmjs.org/@electron-forge/cli/-/cli-7.4.0.tgz", @@ -1688,6 +1701,12 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@formkit/auto-animate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/@formkit/auto-animate/-/auto-animate-0.8.2.tgz", + "integrity": "sha512-SwPWfeRa5veb1hOIBMdzI+73te5puUBHmqqaF1Bu7FjvxlYSz/kJcZKSa9Cg60zL0uRNeJL2SbRxV6Jp6Q1nFQ==", + "license": "MIT" + }, "node_modules/@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", @@ -3696,6 +3715,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -7945,6 +7973,15 @@ "node": ">=12" } }, + "node_modules/lucide-react": { + "version": "0.439.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.439.0.tgz", + "integrity": "sha512-PafSWvDTpxdtNEndS2HIHxcNAbd54OaqSYJO90/b63rab2HWYqDbH194j0i82ZFdWOAcf0AHinRykXRRK2PJbw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -9036,6 +9073,12 @@ "dev": true, "license": "MIT" }, + "node_modules/pg-gateway": { + "version": "0.3.0-alpha.6", + "resolved": "https://registry.npmjs.org/pg-gateway/-/pg-gateway-0.3.0-alpha.6.tgz", + "integrity": "sha512-Av2ujUOokVw1O+P4AtBYek4Np7/2O7bZ9clRVYlCPCeD+NbHj026u+uoiropipsFbf7QyQktJylqbz1aRPGOfA==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", @@ -10938,6 +10981,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tailwind-merge": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.5.2.tgz", + "integrity": "sha512-kjEBm+pvD+6eAwzJL2Bi+02/9LFLal1Gs61+QB7HvTfQQ0aXwC5LGT8PEt1gS0CWKktKe6ysPTAy3cBC5MeiIg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "3.4.10", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.10.tgz", @@ -11465,6 +11518,48 @@ "node": ">= 0.8" } }, + "node_modules/unplugin": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-1.13.1.tgz", + "integrity": "sha512-6Kq1iSSwg7KyjcThRUks9LuqDAKvtnioxbL9iEtB9ctTyBA5OmrB8gZd/d225VJu1w3UpUsKV7eGrvf59J7+VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.12.1", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "webpack-sources": "^3" + }, + "peerDependenciesMeta": { + "webpack-sources": { + "optional": true + } + } + }, + "node_modules/unplugin-fonts": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unplugin-fonts/-/unplugin-fonts-1.1.1.tgz", + "integrity": "sha512-/Aw/rL9D2aslGGM0vi+2R2aG508RSwawLnnBuo+JDSqYc4cHJO1R1phllhN6GysEhBp/6a4B6+vSFPVapWyAAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "^3.2.12", + "unplugin": "^1.3.1" + }, + "peerDependencies": { + "@nuxt/kit": "^3.0.0", + "vite": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + } + } + }, "node_modules/update-browserslist-db": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz", @@ -11642,6 +11737,13 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true, + "license": "MIT" + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", diff --git a/package.json b/package.json index 45b6d8b..a6a8fbc 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "tailwindcss": "^3.4.10", "ts-node": "^10.9.2", "typescript": "~4.5.4", + "unplugin-fonts": "^1.1.1", "vite": "^5.4.3" }, "keywords": [], @@ -47,8 +48,14 @@ }, "license": "MIT", "dependencies": { + "@electric-sql/pglite": "^0.2.5", + "@formkit/auto-animate": "^0.8.2", + "clsx": "^2.1.1", "electron-squirrel-startup": "^1.0.1", + "lucide-react": "^0.439.0", + "pg-gateway": "^0.3.0-alpha.6", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "tailwind-merge": "^2.5.2" } } diff --git a/src/components/App.tsx b/src/components/App.tsx index 4666839..1d3adc8 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -1,9 +1,18 @@ +import { AppBar } from './AppBar' +import { AppProvider } from './AppContext' +import { DatabaseList } from './DatabaseList' +import { StatusBar } from './StatusBar' import { TitleBar } from './TitleBar' export const App = () => { return ( -
- -
+ + <> + + + + + + ) } diff --git a/src/components/AppBar.tsx b/src/components/AppBar.tsx new file mode 100644 index 0000000..9b0b61f --- /dev/null +++ b/src/components/AppBar.tsx @@ -0,0 +1,48 @@ +import { useEffect, useState } from 'react' + +import { useAppUpdate } from './AppContext' + +export const AppBar = () => { + const { setSearch } = useAppUpdate() + const [loading, setLoading] = useState(false) + const [dbName, setDbName] = useState('') + + useEffect(() => { + setSearch(dbName) + }, [dbName]) + + return ( +
+
{ + e.preventDefault() + setLoading(true) + await window.electronAPI.createDatabase(dbName) + setTimeout(() => { + setLoading(false) + setDbName('') + }, 1000) + }} + > + { + if (loading) return + setDbName(e.target.value) + }} + /> + +
+
+ ) +} diff --git a/src/components/AppContext.tsx b/src/components/AppContext.tsx new file mode 100644 index 0000000..d18d7d6 --- /dev/null +++ b/src/components/AppContext.tsx @@ -0,0 +1,56 @@ +import { createContext, useContext, useEffect, useState } from 'react' + +interface AppContextProps { + ready: boolean + search: string + databases: string[] +} + +interface AppUpdatedContextProps { + setReady: (ready: boolean) => void + setSearch: (value: string) => void + setDatabases: (databases: string[]) => void +} + +interface AppProviderProps { + children: React.ReactNode +} + +const AppContext = createContext(null) + +const AppUpdateContext = createContext(null) + +export const AppProvider = ({ children }: AppProviderProps) => { + const [ready, setReady] = useState(false) + const [search, setSearch] = useState('') + const [databases, setDatabases] = useState([]) + + useEffect(() => { + window.electronAPI.startServer() + window.electronAPI.getDatabases() + window.electronAPI.onSetReady(setReady) + window.electronAPI.onSetDatabases(setDatabases) + }, []) + + return ( + + + {children} + + + ) +} + +export const useApp = () => { + const context = useContext(AppContext) + if (!context) throw new Error('Context must be used within an AppProvider') + + return context +} + +export const useAppUpdate = () => { + const context = useContext(AppUpdateContext) + if (!context) throw new Error('Context must be used within an AppProvider') + + return context +} diff --git a/src/components/DatabaseList.tsx b/src/components/DatabaseList.tsx new file mode 100644 index 0000000..ce30783 --- /dev/null +++ b/src/components/DatabaseList.tsx @@ -0,0 +1,33 @@ +import { useAutoAnimate } from '@formkit/auto-animate/react' +import { Database } from 'lucide-react' + +import { useApp } from './AppContext' + +export const DatabaseList = () => { + const app = useApp() + const [listRef] = useAutoAnimate() + + return ( +
    + {app.databases + .filter((db) => !app.search || db.includes(app.search)) + .map((db) => ( +
  • +
    + +
    +
    +

    {db}

    +

    pgquick@localhost:5432

    +
    +
  • + ))} +
+ ) +} diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx new file mode 100644 index 0000000..a7e0bbd --- /dev/null +++ b/src/components/StatusBar.tsx @@ -0,0 +1,22 @@ +import { PGLITE_VERSION, PG_VERSION } from '../constants' +import { clsxMerge } from '../utils/clsxMerge' +import { useApp } from './AppContext' + +export const StatusBar = () => { + const app = useApp() + + return ( +
+
{`PostgreSQL ${PG_VERSION} (PGlite ${PGLITE_VERSION})`}
+
+ {app.ready ? 'Online' : 'Offline'} + +
+
+ ) +} diff --git a/src/components/TitleBar.tsx b/src/components/TitleBar.tsx index 6bf5eb9..7f1401b 100644 --- a/src/components/TitleBar.tsx +++ b/src/components/TitleBar.tsx @@ -1,11 +1,13 @@ export const TitleBar = () => { return (
+ > +

PGQuick

+ ) } diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..6910cdb --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,2 @@ +export const PG_VERSION = '16.4' +export const PGLITE_VERSION = '0.2.5' diff --git a/src/index.css b/src/index.css index b5c61c9..e5c7e88 100644 --- a/src/index.css +++ b/src/index.css @@ -1,3 +1,11 @@ @tailwind base; @tailwind components; @tailwind utilities; + +html { + @apply h-full; +} + +body { + @apply h-full; +} diff --git a/src/lib/db.ts b/src/lib/db.ts new file mode 100644 index 0000000..651309f --- /dev/null +++ b/src/lib/db.ts @@ -0,0 +1,60 @@ +import { PGlite } from '@electric-sql/pglite' +import { IpcMainEvent, app } from 'electron' +import { mkdir, readdir, rmdir } from 'node:fs/promises' + +export let databases: string[] = [] + +export const getDatabases = async (event: IpcMainEvent) => { + try { + const files = await readdir(`${app.getPath('userData')}/dbs`, { + withFileTypes: true, + }) + + databases = files.filter((f) => f.isDirectory()).map((f) => f.name) + + event.sender.send('set-databases', databases) + } catch (error) { + /* empty */ + } +} + +export const createDatabase = async (event: IpcMainEvent, name: string) => { + try { + await mkdir(`${app.getPath('userData')}/dbs`) + } catch (error) { + /* empty */ + } + + const db = new PGlite(`${app.getPath('userData')}/dbs/${name}`) + await db.waitReady + + await db.exec(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT FROM pg_catalog.pg_roles WHERE rolname = 'pgquick' + ) THEN + CREATE ROLE pgquick WITH LOGIN; + END IF; + END $$; + `) + + try { + await db.exec(`CREATE DATABASE "${name}" OWNER pgquick;`) + } catch (error) { + /* empty */ + } + + db.close() + getDatabases(event) +} + +export const removeDatabase = async (event: IpcMainEvent, name: string) => { + try { + await rmdir(`${app.getPath('userData')}/dbs/${name}`) + } catch (error) { + /* empty */ + } + + getDatabases(event) +} diff --git a/src/lib/server.ts b/src/lib/server.ts new file mode 100644 index 0000000..9854c21 --- /dev/null +++ b/src/lib/server.ts @@ -0,0 +1,77 @@ +import { PGlite } from '@electric-sql/pglite' +import { IpcMainEvent, app } from 'electron' +import { Server, createServer } from 'node:net' +import { PostgresConnection } from 'pg-gateway' + +import { PGLITE_VERSION, PG_VERSION } from '../constants' +import { databases } from './db' + +let server: Server | null = null +const dbs: Record = {} + +export const startServer = (event: IpcMainEvent) => { + if (server) { + event.sender.send('set-ready', true) + return + } + + server = createServer((socket) => { + const connection = new PostgresConnection(socket, { + serverVersion: `${PG_VERSION} (PGlite ${PGLITE_VERSION})`, + auth: { + method: 'password', + validateCredentials: ({ username }) => username === 'pgquick', + getClearTextPassword: () => '', + }, + onStartup: async ({ clientInfo }) => { + if (!clientInfo) return + + const dbName = clientInfo.parameters['database'] + if (!dbName || !databases.includes(dbName) || dbs[dbName]) return + + dbs[dbName] = new PGlite(`${app.getPath('userData')}/dbs/${dbName}`, { + username: 'pgquick', + database: dbName, + }) + + await dbs[dbName].waitReady + }, + onMessage: async (data, { isAuthenticated, clientInfo }) => { + if (!isAuthenticated || !clientInfo) return false + + try { + const dbName = clientInfo.parameters['database'] + + if (!dbName || !dbs[dbName]) throw new Error('Database not found.') + + const [[, responseData]] = await dbs[dbName].execProtocol(data) + connection.sendData(responseData) + } catch (err) { + connection.sendError(err) + connection.sendReadyForQuery() + } + return true + }, + }) + }) + + server.listen(5432, () => { + console.info('Server listening on port 5432') + event.sender.send('set-ready', true) + }) +} + +export const stopServer = async (event: IpcMainEvent) => { + await new Promise((resolve) => { + if (!server) return resolve() + server.close(() => resolve()) + }) + + event.sender.send('set-ready', false) + server = null +} + +export const restartServer = async (event: IpcMainEvent) => { + await stopServer(event) + startServer(event) +} diff --git a/src/main.ts b/src/main.ts index 5d34d40..c85ed05 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,9 @@ -import { BrowserWindow, app } from 'electron' +import { BrowserWindow, app, ipcMain } from 'electron' import path from 'path' +import { createDatabase, getDatabases, removeDatabase } from './lib/db' +import { restartServer, startServer, stopServer } from './lib/server' + // Handle creating/removing shortcuts on Windows when installing/uninstalling. if (require('electron-squirrel-startup')) { app.quit() @@ -9,19 +12,25 @@ if (require('electron-squirrel-startup')) { const createWindow = () => { // Create the browser window. const mainWindow = new BrowserWindow({ - width: 400, - height: 500, + width: 360, + height: 508, + backgroundColor: '#000000', + resizable: false, webPreferences: { preload: path.join(__dirname, 'preload.js'), + devTools: !app.isPackaged, }, titleBarStyle: 'hidden', + trafficLightPosition: { x: 12, y: 8 }, }) // and load the index.html of the app. if (MAIN_WINDOW_VITE_DEV_SERVER_URL) { mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL) } else { - mainWindow.loadFile(path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`)) + mainWindow.loadFile( + path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`), + ) } // Open the DevTools. @@ -52,3 +61,10 @@ app.on('activate', () => { // In this file you can include the rest of your app's specific main process // code. You can also put them in separate files and import them here. + +ipcMain.on('start-server', startServer) +ipcMain.on('stop-server', stopServer) +ipcMain.on('restart-server', restartServer) +ipcMain.on('get-databases', getDatabases) +ipcMain.handle('create-database', createDatabase) +ipcMain.on('remove-database', removeDatabase) diff --git a/src/preload.ts b/src/preload.ts index 5e9d369..bc43e85 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -1,2 +1,18 @@ // See the Electron documentation for details on how to use preload scripts: // https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts +import { contextBridge, ipcRenderer } from 'electron' + +contextBridge.exposeInMainWorld('electronAPI', { + startServer: () => ipcRenderer.send('start-server'), + stopServer: () => ipcRenderer.send('stop-server'), + restartServer: () => ipcRenderer.send('restart-server'), + getDatabases: () => ipcRenderer.send('get-databases'), + createDatabase: (name: string) => ipcRenderer.invoke('create-database', name), + removeDatabase: (name: string) => ipcRenderer.send('remove-database', name), + onSetReady: (callback: (value: boolean) => void) => + ipcRenderer.on('set-ready', (_event, value: boolean) => callback(value)), + onSetDatabases: (callback: (value: string[]) => void) => + ipcRenderer.on('set-databases', (_event, value: string[]) => + callback(value), + ), +}) diff --git a/src/utils/clsxMerge.ts b/src/utils/clsxMerge.ts new file mode 100644 index 0000000..93d848b --- /dev/null +++ b/src/utils/clsxMerge.ts @@ -0,0 +1,6 @@ +import clsx, { ClassValue } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export const clsxMerge = (...args: ClassValue[]) => { + return twMerge(clsx(args)) +} diff --git a/tsconfig.json b/tsconfig.json index 0462f03..2933dc6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,7 @@ "outDir": "dist", "moduleResolution": "node", "resolveJsonModule": true, - "jsx": "react-jsx" + "jsx": "react-jsx", + "strictNullChecks": true } } diff --git a/vite.renderer.config.ts b/vite.renderer.config.ts index 1aba05f..da525c8 100644 --- a/vite.renderer.config.ts +++ b/vite.renderer.config.ts @@ -1,3 +1,5 @@ +// eslint-disable-next-line import/no-unresolved +import Unfonts from 'unplugin-fonts/vite' import type { ConfigEnv, UserConfig } from 'vite' import { defineConfig } from 'vite' @@ -16,7 +18,14 @@ export default defineConfig((env) => { build: { outDir: `.vite/renderer/${name}`, }, - plugins: [pluginExposeRenderer(name)], + plugins: [ + pluginExposeRenderer(name), + Unfonts({ + google: { + families: [{ name: 'Dongle' }], + }, + }), + ], resolve: { preserveSymlinks: true, },