├── .npmrc ├── src ├── renderer │ ├── src │ │ ├── env.d.ts │ │ ├── App.tsx │ │ ├── lib │ │ │ ├── cn.ts │ │ │ ├── query-client.ts │ │ │ ├── utils.ts │ │ │ ├── copy.ts │ │ │ ├── config.ts │ │ │ ├── store.ts │ │ │ ├── actions-proxy.ts │ │ │ ├── database.ts │ │ │ ├── native-menu.ts │ │ │ ├── codemirror.ts │ │ │ └── openai.ts │ │ ├── components │ │ │ ├── Control.tsx │ │ │ ├── control.tsx │ │ │ ├── updater-button.tsx │ │ │ ├── sidebar-section.tsx │ │ │ ├── ui-tooltip.tsx │ │ │ ├── Spinner.tsx │ │ │ ├── spinner.tsx │ │ │ ├── Tooltip.tsx │ │ │ ├── tooltip.tsx │ │ │ ├── Popover.tsx │ │ │ ├── popover.tsx │ │ │ ├── database-flow.tsx │ │ │ ├── Input.tsx │ │ │ ├── input.tsx │ │ │ ├── connections-menu.tsx │ │ │ ├── Sidebar.tsx │ │ │ ├── sidebar.tsx │ │ │ ├── Table.tsx │ │ │ ├── table.tsx │ │ │ ├── Button.tsx │ │ │ ├── button.tsx │ │ │ ├── Dialog.tsx │ │ │ ├── dialog.tsx │ │ │ ├── settings-dialog.tsx │ │ │ ├── data-table.tsx │ │ │ ├── Dropdown.tsx │ │ │ └── dropdown.tsx │ │ ├── pages │ │ │ ├── connections │ │ │ │ └── [id] │ │ │ │ │ ├── schema.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── queries │ │ │ │ │ └── [queryId].tsx │ │ │ ├── updater.tsx │ │ │ └── index.tsx │ │ ├── css │ │ │ ├── tailwind.css │ │ │ └── spinner.css │ │ └── main.tsx │ └── index.html ├── shared │ ├── env.d.ts │ ├── utils.ts │ ├── constants.ts │ └── types.ts ├── preload │ ├── index.d.ts │ └── index.ts └── main │ ├── config.ts │ ├── app-db.ts │ ├── app-schema.ts │ ├── actions.d.ts │ ├── cors.ts │ ├── serve.ts │ ├── index.ts │ ├── windows.ts │ ├── updater.ts │ ├── actions.ts │ └── connection.ts ├── .vscode ├── extensions.json ├── settings.json └── launch.json ├── resources └── icon.png ├── .gitignore ├── .prettierrc.yaml ├── postcss.config.cjs ├── .prettierignore ├── dev-app-update.yml ├── tsconfig.json ├── .editorconfig ├── drizzle.config.ts ├── migrations ├── meta │ ├── _journal.json │ └── 0000_snapshot.json └── 0000_bored_george_stacy.sql ├── tsconfig.node.json ├── README.md ├── tsconfig.web.json ├── scripts └── release.js ├── electron.vite.config.ts ├── tailwind.config.cjs ├── electron-builder.yml ├── electron-builder.config.cjs └── package.json /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | -------------------------------------------------------------------------------- /src/renderer/src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["dbaeumer.vscode-eslint"] 3 | } 4 | -------------------------------------------------------------------------------- /resources/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egoist/querybase/HEAD/resources/icon.png -------------------------------------------------------------------------------- /src/shared/env.d.ts: -------------------------------------------------------------------------------- 1 | declare const APP_VERSION: string 2 | declare const APP_NAME: string 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | out 4 | .DS_Store 5 | *.log* 6 | *.db 7 | *.db-* 8 | .env 9 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | singleQuote: true 2 | semi: false 3 | printWidth: 100 4 | trailingComma: none 5 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {} 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | out 2 | dist 3 | pnpm-lock.yaml 4 | LICENSE.md 5 | tsconfig.json 6 | tsconfig.*.json 7 | -------------------------------------------------------------------------------- /dev-app-update.yml: -------------------------------------------------------------------------------- 1 | provider: github 2 | owner: egoist 3 | repo: querybase 4 | updaterCacheDirName: querybase-updater 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }] 4 | } 5 | -------------------------------------------------------------------------------- /src/preload/index.d.ts: -------------------------------------------------------------------------------- 1 | import { ElectronAPI } from '@electron-toolkit/preload' 2 | 3 | declare global { 4 | interface Window { 5 | electron: ElectronAPI 6 | api: unknown 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true -------------------------------------------------------------------------------- /src/renderer/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from 'react-router-dom' 2 | 3 | function App() { 4 | return ( 5 | <> 6 | 7 | 8 | ) 9 | } 10 | 11 | export default App 12 | -------------------------------------------------------------------------------- /src/renderer/src/lib/cn.ts: -------------------------------------------------------------------------------- 1 | import { clsx, ClassValue } from 'clsx' 2 | import { twMerge } from 'tailwind-merge' 3 | 4 | export function cn(...args: ClassValue[]) { 5 | return twMerge(clsx(...args)) 6 | } 7 | -------------------------------------------------------------------------------- /src/renderer/src/lib/query-client.ts: -------------------------------------------------------------------------------- 1 | import { QueryClient } from '@tanstack/react-query' 2 | 3 | export const queryClient = new QueryClient({ 4 | defaultOptions: { 5 | queries: { 6 | networkMode: 'always' 7 | } 8 | } 9 | }) 10 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'drizzle-kit' 2 | 3 | export default { 4 | dialect: 'sqlite', 5 | schema: './src/main/app-schema.ts', 6 | out: './migrations', 7 | dbCredentials: { 8 | url: 'file:temp/app.db' 9 | } 10 | } satisfies Config 11 | -------------------------------------------------------------------------------- /migrations/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "sqlite", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "5", 8 | "when": 1712159217628, 9 | "tag": "0000_bored_george_stacy", 10 | "breakpoints": true 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[typescript]": { 3 | "editor.defaultFormatter": "esbenp.prettier-vscode" 4 | }, 5 | "[javascript]": { 6 | "editor.defaultFormatter": "esbenp.prettier-vscode" 7 | }, 8 | "[json]": { 9 | "editor.defaultFormatter": "esbenp.prettier-vscode" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@electron-toolkit/tsconfig/tsconfig.node.json", 3 | "include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*", "src/shared/**/*"], 4 | "compilerOptions": { 5 | "composite": true, 6 | "types": ["electron-vite/node"], 7 | "baseUrl": ".", 8 | "paths": { 9 | "@shared/*":[ 10 | "src/shared/*" 11 | ] 12 | } 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /src/renderer/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { customAlphabet } from 'nanoid' 2 | 3 | export const genId = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyz', 8) 4 | 5 | export const formatError = (error: unknown) => { 6 | let message = '' 7 | 8 | if (typeof error === 'string') { 9 | message = error 10 | } else if (error instanceof Error) { 11 | message = error.message 12 | } else { 13 | message = JSON.stringify(error) 14 | } 15 | 16 | return message.replace(/Error invoking remote method '.+':\s+(error:\s+)?/, '') 17 | } 18 | -------------------------------------------------------------------------------- /src/renderer/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Electron 6 | 7 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/renderer/src/components/Control.tsx: -------------------------------------------------------------------------------- 1 | export const Control = ({ 2 | label, 3 | desc, 4 | children, 5 | className 6 | }: { 7 | label: React.ReactNode 8 | desc?: React.ReactNode 9 | children: React.ReactNode 10 | className?: string 11 | }) => { 12 | return ( 13 |
14 |
15 | {label} 16 |
17 |
{children}
18 | {desc &&
{desc}
} 19 |
20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/renderer/src/components/control.tsx: -------------------------------------------------------------------------------- 1 | export const Control = ({ 2 | label, 3 | desc, 4 | children, 5 | className 6 | }: { 7 | label: React.ReactNode 8 | desc?: React.ReactNode 9 | children: React.ReactNode 10 | className?: string 11 | }) => { 12 | return ( 13 |
14 |
15 | {label} 16 |
17 |
{children}
18 | {desc &&
{desc}
} 19 |
20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/main/config.ts: -------------------------------------------------------------------------------- 1 | import { app } from 'electron' 2 | import fs from 'fs' 3 | import path from 'path' 4 | import { Config } from '@shared/types' 5 | 6 | const configPath = path.join(app.getPath('userData'), 'config.json') 7 | 8 | export const loadConfig = async (): Promise => { 9 | try { 10 | return JSON.parse(fs.readFileSync(configPath, 'utf-8')) 11 | } catch { 12 | return {} 13 | } 14 | } 15 | 16 | export const saveConfig = async (config: Config) => { 17 | fs.mkdirSync(path.dirname(configPath), { recursive: true }) 18 | fs.writeFileSync(configPath, JSON.stringify(config)) 19 | } 20 | -------------------------------------------------------------------------------- /src/renderer/src/lib/copy.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef, useState } from 'react' 2 | 3 | export const useCopy = () => { 4 | const [copied, setCopied] = useState(false) 5 | const timeoutRef = useRef() 6 | 7 | const copy = useCallback((text: string, timeout = 1000) => { 8 | if (timeoutRef.current) { 9 | window.clearTimeout(timeoutRef.current) 10 | } 11 | setCopied(true) 12 | navigator.clipboard.writeText(text) 13 | timeoutRef.current = window.setTimeout(() => { 14 | setCopied(false) 15 | }, timeout) 16 | }, []) 17 | 18 | return [copy, copied] as const 19 | } 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # QueryBase 2 | 3 | A simple and smart GUI client for PostgreSQL, MySQL, SQLite. 4 | 5 | ## Project Status 6 | 7 | Beta, I mean it's written in 5 days. 8 | 9 | ## Features 10 | 11 | - Support PostgreSQL, MySQL, SQLite 12 | - Execute and save SQL quieries 13 | - Generate SQL queries with AI 14 | - Fix SQL query errors with AI 15 | 16 | ## Download 17 | 18 | Currently for macOS only, you can build it from source code for other platforms but it may not work. I plan to release for Windows when the project is more stable. 19 | 20 | https://github.com/egoist/querybase/releases/latest 21 | 22 | ## License 23 | 24 | AGPL-3.0 25 | -------------------------------------------------------------------------------- /tsconfig.web.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@electron-toolkit/tsconfig/tsconfig.web.json", 3 | "include": [ 4 | "src/renderer/src/env.d.ts", 5 | "src/renderer/src/**/*", 6 | "src/shared/**/*", 7 | "src/renderer/src/**/*.tsx", 8 | "src/preload/*.d.ts" 9 | ], 10 | "compilerOptions": { 11 | "composite": true, 12 | "jsx": "react-jsx", 13 | "noUnusedLocals": false, 14 | "forceConsistentCasingInFileNames": true, 15 | "baseUrl": ".", 16 | "paths": { 17 | "@renderer/*": [ 18 | "src/renderer/src/*" 19 | ], 20 | "@shared/*":[ 21 | "src/shared/*" 22 | ] 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /migrations/0000_bored_george_stacy.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `connection` ( 2 | `id` text PRIMARY KEY NOT NULL, 3 | `createdAt` integer NOT NULL, 4 | `nickname` text NOT NULL, 5 | `type` text NOT NULL, 6 | `host` text, 7 | `port` text, 8 | `user` text, 9 | `config` text, 10 | `password` text, 11 | `database` text NOT NULL 12 | ); 13 | --> statement-breakpoint 14 | CREATE TABLE `query` ( 15 | `id` text PRIMARY KEY NOT NULL, 16 | `createdAt` integer NOT NULL, 17 | `connectionId` text NOT NULL, 18 | `title` text NOT NULL, 19 | `query` text NOT NULL 20 | ); 21 | --> statement-breakpoint 22 | CREATE INDEX `query_connectionId_idx` ON `query` (`connectionId`); -------------------------------------------------------------------------------- /src/shared/utils.ts: -------------------------------------------------------------------------------- 1 | export function notEmpty(value: T | null | undefined | '' | false): value is T { 2 | return value !== null && value !== undefined && value !== '' && value !== false 3 | } 4 | 5 | export function stringifyUrl(url: { 6 | protocol: string 7 | host: string 8 | port?: string | null 9 | pathname?: string | null 10 | query?: string | null 11 | user?: string | null 12 | password?: string | null 13 | }) { 14 | return `${url.protocol}//${url.user ? `${url.user}:${url.password ? `${url.password}@` : ''}` : ''}${url.host}${url.port ? `:${url.port}` : ''}${url.pathname ? `${url.pathname}${url.query ? `?${url.query}` : ''}` : ''}` 15 | } 16 | -------------------------------------------------------------------------------- /src/preload/index.ts: -------------------------------------------------------------------------------- 1 | import { contextBridge } from 'electron' 2 | import { electronAPI } from '@electron-toolkit/preload' 3 | 4 | // Custom APIs for renderer 5 | const api = {} 6 | 7 | // Use `contextBridge` APIs to expose Electron APIs to 8 | // renderer only if context isolation is enabled, otherwise 9 | // just add to the DOM global. 10 | if (process.contextIsolated) { 11 | try { 12 | contextBridge.exposeInMainWorld('electron', electronAPI) 13 | contextBridge.exposeInMainWorld('api', api) 14 | } catch (error) { 15 | console.error(error) 16 | } 17 | } else { 18 | // @ts-ignore (define in dts) 19 | window.electron = electronAPI 20 | // @ts-ignore (define in dts) 21 | window.api = api 22 | } 23 | -------------------------------------------------------------------------------- /src/renderer/src/lib/config.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query' 2 | import { actionsProxy } from './actions-proxy' 3 | import { Config } from '@shared/types' 4 | import { queryClient } from './query-client' 5 | 6 | export const useConfig = () => 7 | useQuery({ 8 | queryKey: ['config'], 9 | queryFn: () => actionsProxy.loadConfig.invoke() 10 | }) 11 | 12 | export const saveConfig = async (partialConfig: Partial) => { 13 | queryClient.setQueryData(['config'], (prev) => { 14 | if (!prev) return prev 15 | return { ...prev, ...partialConfig } 16 | }) 17 | 18 | const currentConfig = await actionsProxy.loadConfig.invoke() 19 | await actionsProxy.saveConfig.invoke({ config: { ...currentConfig, ...partialConfig } }) 20 | } 21 | -------------------------------------------------------------------------------- /src/renderer/src/components/updater-button.tsx: -------------------------------------------------------------------------------- 1 | import { actionsProxy } from '@renderer/lib/actions-proxy' 2 | import { footerButtonVariants } from './sidebar' 3 | 4 | export const DesktopUpdaterButton = () => { 5 | const checkUpdatesQuery = actionsProxy.checkForUpdates.useQuery(undefined, { 6 | refetchOnWindowFocus: true, 7 | refetchInterval: 5 * 60 * 1000 8 | }) 9 | 10 | if (!checkUpdatesQuery.data) { 11 | return null 12 | } 13 | 14 | return ( 15 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/renderer/src/components/sidebar-section.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@renderer/lib/cn' 2 | 3 | export const SidebarSection = ({ 4 | title, 5 | titleEndContent, 6 | children, 7 | scroll, 8 | className 9 | }: { 10 | title?: string 11 | titleEndContent?: React.ReactNode 12 | children: React.ReactNode 13 | scroll?: boolean 14 | className?: string 15 | }) => { 16 | return ( 17 | <> 18 | {title && ( 19 |
20 | {title} 21 | 22 | {titleEndContent} 23 |
24 | )} 25 |
26 | {children} 27 |
28 | 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /src/renderer/src/components/ui-tooltip.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react' 2 | import { Tooltip, TooltipContent, TooltipTrigger } from './tooltip' 3 | import { PopperContentProps } from '@radix-ui/react-tooltip' 4 | 5 | export const UITooltip = memo( 6 | ({ 7 | children, 8 | content, 9 | side, 10 | align 11 | }: { 12 | children: React.ReactNode 13 | content: React.ReactNode 14 | side?: PopperContentProps['side'] 15 | align?: PopperContentProps['align'] 16 | }) => { 17 | if (!content) return <>{children} 18 | 19 | return ( 20 | 21 | {children} 22 | 23 | {content} 24 | 25 | 26 | ) 27 | } 28 | ) 29 | -------------------------------------------------------------------------------- /src/main/app-db.ts: -------------------------------------------------------------------------------- 1 | import * as appSchema from './app-schema' 2 | import fs from 'fs' 3 | import { drizzle } from 'drizzle-orm/libsql' 4 | import { migrate } from 'drizzle-orm/libsql/migrator' 5 | import { createClient } from '@libsql/client' 6 | import path from 'path' 7 | import { app } from 'electron' 8 | 9 | const dbPath = import.meta.env.DEV ? 'temp/app.db' : path.join(app.getPath('userData'), 'app.db') 10 | 11 | fs.mkdirSync(path.dirname(dbPath), { recursive: true }) 12 | 13 | const db = createClient({ 14 | url: `file:${dbPath}` 15 | }) 16 | 17 | export const appDB = drizzle(db, { schema: appSchema }) 18 | 19 | export { appSchema } 20 | 21 | export const runMigrations = async () => { 22 | // Enable WAL 23 | await db.execute('PRAGMA journal_mode = WAL') 24 | 25 | if (import.meta.env.PROD) { 26 | await migrate(appDB, { 27 | migrationsFolder: path.join(__dirname, '../../migrations') 28 | }) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /scripts/release.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { execSync } from 'child_process' 3 | 4 | /** 5 | * 6 | * @param {string} command 7 | * @param {{cwd?: string}} options 8 | * @returns 9 | */ 10 | const run = (command, { cwd } = {}) => { 11 | return execSync(command, { 12 | cwd, 13 | stdio: 'inherit', 14 | env: { 15 | ...process.env, 16 | ...(process.platform === 'darwin' 17 | ? { 18 | CSC_LINK: process.env.CSC_LINK, 19 | CSC_KEY_PASSWORD: process.env.CSC_KEY_PASSWORD, 20 | APPLE_ID: process.env.APPLE_ID, 21 | APPLE_APP_SPECIFIC_PASSWORD: process.env.APPLE_PASSWORD 22 | } 23 | : {}) 24 | } 25 | }) 26 | } 27 | 28 | const desktopDir = process.cwd() 29 | 30 | if (process.platform === 'darwin') { 31 | run(`pnpm build:mac --arm64 --x64 --publish always`, { 32 | cwd: desktopDir 33 | }) 34 | } else { 35 | run(`pnpm build:win --publish always`, { 36 | cwd: desktopDir 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /src/renderer/src/lib/store.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query' 2 | import { actionsProxy } from './actions-proxy' 3 | 4 | export const useSchema = (connectionId?: string) => 5 | useQuery({ 6 | retry: false, 7 | refetchOnWindowFocus: false, 8 | enabled: Boolean(connectionId), 9 | queryKey: ['databaseSchema', connectionId], 10 | queryFn: async () => { 11 | if (!connectionId) return null 12 | return actionsProxy.connectDatabase.invoke({ connectionId }).then((connection) => { 13 | if (!connection) { 14 | throw new Error('database not found') 15 | } 16 | 17 | return actionsProxy.getDatabaseSchema.invoke({ connection }) 18 | }) 19 | } 20 | }) 21 | 22 | export const useSavedQueries = (connectionId: string) => { 23 | return actionsProxy.getQueries.useQuery({ 24 | connectionId 25 | }) 26 | } 27 | 28 | export const useConnections = () => { 29 | return actionsProxy.getConnections.useQuery() 30 | } 31 | -------------------------------------------------------------------------------- /electron.vite.config.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import { resolve } from 'path' 3 | import { defineConfig, externalizeDepsPlugin } from 'electron-vite' 4 | import react from '@vitejs/plugin-react' 5 | 6 | const pkg = JSON.parse(fs.readFileSync('package.json', 'utf-8')) 7 | 8 | export default defineConfig({ 9 | main: { 10 | plugins: [externalizeDepsPlugin()], 11 | resolve: { 12 | alias: { 13 | '@shared': resolve('src/shared') 14 | } 15 | }, 16 | build: { 17 | rollupOptions: { 18 | output: { 19 | format: 'es' 20 | } 21 | } 22 | } 23 | }, 24 | preload: { 25 | plugins: [externalizeDepsPlugin()] 26 | }, 27 | renderer: { 28 | resolve: { 29 | alias: { 30 | '@renderer': resolve('src/renderer/src'), 31 | '@shared': resolve('src/shared') 32 | } 33 | }, 34 | plugins: [react()], 35 | define: { 36 | APP_VERSION: JSON.stringify(pkg.version), 37 | APP_NAME: JSON.stringify(pkg.productName) 38 | } 39 | } 40 | }) 41 | -------------------------------------------------------------------------------- /src/renderer/src/pages/connections/[id]/schema.tsx: -------------------------------------------------------------------------------- 1 | import { DatabaseFlow } from '@renderer/components/database-flow' 2 | import { useSchema } from '@renderer/lib/store' 3 | import { formatError } from '@renderer/lib/utils' 4 | import { useParams } from 'react-router-dom' 5 | 6 | export function Component() { 7 | const params = useParams<{ id: string }>() 8 | const connectionId = params.id! 9 | const schemaQuery = useSchema(connectionId) 10 | 11 | return ( 12 | <> 13 |
14 |

Schema

15 |
16 | {schemaQuery.data ? ( 17 | 18 | ) : schemaQuery.error ? ( 19 |
20 |
21 | {formatError(schemaQuery.error)} 22 |
23 |
24 | ) : null} 25 | 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug Main Process", 6 | "type": "node", 7 | "request": "launch", 8 | "cwd": "${workspaceRoot}", 9 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite", 10 | "windows": { 11 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd" 12 | }, 13 | "runtimeArgs": ["--sourcemap"], 14 | "env": { 15 | "REMOTE_DEBUGGING_PORT": "9222" 16 | } 17 | }, 18 | { 19 | "name": "Debug Renderer Process", 20 | "port": 9222, 21 | "request": "attach", 22 | "type": "chrome", 23 | "webRoot": "${workspaceFolder}/src/renderer", 24 | "timeout": 60000, 25 | "presentation": { 26 | "hidden": true 27 | } 28 | } 29 | ], 30 | "compounds": [ 31 | { 32 | "name": "Debug All", 33 | "configurations": ["Debug Main Process", "Debug Renderer Process"], 34 | "presentation": { 35 | "order": 1 36 | } 37 | } 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /src/shared/constants.ts: -------------------------------------------------------------------------------- 1 | export const models = [ 2 | { 3 | label: 'GPT-3.5 Turbo', 4 | value: 'gpt-3.5-turbo' as const 5 | }, 6 | { 7 | label: 'GPT-4 Turbo', 8 | value: 'gpt-4-turbo' as const, 9 | realModelId: 'gpt-4-turbo-preview' 10 | }, 11 | { 12 | label: 'GPT-4o', 13 | value: 'gpt-4o' as const 14 | }, 15 | { 16 | label: 'GPT-4o mini', 17 | value: 'gpt-4o-mini' as const 18 | }, 19 | { 20 | label: 'Claude 3 Haiku', 21 | value: 'claude-3-haiku' as const, 22 | realModelId: 'claude-3-haiku-20240307' 23 | }, 24 | { 25 | label: 'Claude 3 Sonnet', 26 | value: 'claude-3-sonnet' as const, 27 | realModelId: 'claude-3-sonnet-20240229' 28 | }, 29 | { 30 | label: 'Claude 3 Opus', 31 | value: 'claude-3-opus' as const, 32 | realModelId: 'claude-3-opus-latest' 33 | }, 34 | { 35 | label: 'Claude 3.5 Sonnet', 36 | value: 'claude-3.5-sonnet' as const, 37 | realModelId: 'claude-3-5-sonnet-latest' 38 | }, 39 | { 40 | label: 'Deepseek Chat', 41 | value: 'deepseek-chat' as const 42 | } 43 | ] 44 | 45 | export type ModelId = (typeof models)[number]['value'] 46 | -------------------------------------------------------------------------------- /tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | const { iconsPlugin, getIconCollections } = require('@egoist/tailwindcss-icons') 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | module.exports = { 5 | darkMode: 'class', 6 | content: ['./src/**/*.tsx'], 7 | theme: { 8 | extend: { 9 | fontFamily: { 10 | mono: 'var(--font-mono)' 11 | }, 12 | height: { 13 | 'editor-wrapper': 'var(--editor-wrapper-height)' 14 | }, 15 | backgroundColor: { 16 | popover: 'var(--popover-bg)', 17 | modal: 'var(--modal-bg)', 18 | 'modal-overlay': 'var(--modal-overlay-bg)' 19 | }, 20 | boxShadow: { 21 | modal: 'var(--modal-shadow)' 22 | }, 23 | borderColor: { 24 | modal: 'var(--modal-border-color)' 25 | }, 26 | colors: { 27 | popover: 'var(--popover-fg)' 28 | } 29 | } 30 | }, 31 | plugins: [ 32 | iconsPlugin({ 33 | collections: getIconCollections([ 34 | 'mingcute', 35 | 'ri', 36 | 'tabler', 37 | 'material-symbols', 38 | 'lucide', 39 | 'devicon', 40 | 'logos' 41 | ]) 42 | }) 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /src/renderer/src/components/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import { cn } from '@renderer/lib/cn' 3 | 4 | export const Spinner = ({ className, delay }: { className?: string; delay?: number }) => { 5 | const [show, setShow] = useState(!delay) 6 | 7 | useEffect(() => { 8 | const id = window.setTimeout(() => { 9 | setShow(true) 10 | }, delay) 11 | 12 | return () => { 13 | window.clearTimeout(id) 14 | } 15 | }, [delay]) 16 | 17 | if (!show) { 18 | return null 19 | } 20 | 21 | return ( 22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /src/renderer/src/components/spinner.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import { cn } from '@renderer/lib/cn' 3 | 4 | export const Spinner = ({ className, delay }: { className?: string; delay?: number }) => { 5 | const [show, setShow] = useState(!delay) 6 | 7 | useEffect(() => { 8 | const id = window.setTimeout(() => { 9 | setShow(true) 10 | }, delay) 11 | 12 | return () => { 13 | window.clearTimeout(id) 14 | } 15 | }, [delay]) 16 | 17 | if (!show) { 18 | return null 19 | } 20 | 21 | return ( 22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /src/renderer/src/lib/actions-proxy.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQuery } from '@tanstack/react-query' 2 | import type { ActionsProxy } from '../../../main/actions.d' 3 | import { queryClient } from './query-client' 4 | 5 | export const actionsProxy = new Proxy({} as any, { 6 | get: (_, prop) => { 7 | const invoke = (input: any) => { 8 | return window.electron.ipcRenderer.invoke(prop.toString(), input) 9 | } 10 | 11 | return { 12 | invoke, 13 | 14 | useMutation: (mutationOptions?: any) => { 15 | return useMutation({ 16 | ...mutationOptions, 17 | mutationFn: invoke 18 | }) 19 | }, 20 | 21 | useQuery: (variables: any, queryOptions?: any) => { 22 | return useQuery({ 23 | ...queryOptions, 24 | queryKey: [prop.toString(), variables], 25 | queryFn: () => invoke(variables) 26 | }) 27 | }, 28 | 29 | setQueryData: (variables: unknown, updater: unknown) => { 30 | return queryClient.setQueryData([prop.toString(), variables], updater) 31 | }, 32 | 33 | removeQueryCache: () => { 34 | queryClient.removeQueries({ 35 | queryKey: [prop.toString()] 36 | }) 37 | } 38 | } 39 | } 40 | }) 41 | -------------------------------------------------------------------------------- /src/main/app-schema.ts: -------------------------------------------------------------------------------- 1 | import { ConnectionConfig, ConnectionType } from '@shared/types' 2 | import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core' 3 | import { customAlphabet } from 'nanoid' 4 | 5 | const genId = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyz', 8) 6 | 7 | const defaultRandom = () => genId() 8 | 9 | const defaultNow = () => new Date() 10 | 11 | export const connection = sqliteTable('connection', { 12 | id: text().primaryKey().$defaultFn(defaultRandom), 13 | createdAt: integer({ mode: 'timestamp_ms' }).notNull().$defaultFn(defaultNow), 14 | nickname: text().notNull(), 15 | type: text().$type().notNull(), 16 | host: text(), 17 | port: text(), 18 | user: text(), 19 | config: text({ mode: 'json' }).$type(), 20 | password: text(), 21 | database: text().notNull() 22 | }) 23 | 24 | export const query = sqliteTable( 25 | 'query', 26 | { 27 | id: text().primaryKey().$defaultFn(defaultRandom), 28 | createdAt: integer({ mode: 'timestamp_ms' }).notNull().$defaultFn(defaultNow), 29 | connectionId: text().notNull(), 30 | title: text().notNull(), 31 | query: text().notNull() 32 | }, 33 | (t) => { 34 | return { 35 | connectionId_idx: index('query_connectionId_idx').on(t.connectionId) 36 | } 37 | } 38 | ) 39 | -------------------------------------------------------------------------------- /src/renderer/src/components/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as TooltipPrimitive from '@radix-ui/react-tooltip' 3 | 4 | import { cn } from '@renderer/lib/cn' 5 | 6 | const TooltipProvider = TooltipPrimitive.Provider 7 | 8 | const Tooltip = TooltipPrimitive.Root 9 | 10 | const TooltipTrigger = TooltipPrimitive.Trigger 11 | 12 | const TooltipContent = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, sideOffset = 4, ...props }, ref) => ( 16 | 17 | 26 | 27 | )) 28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName 29 | 30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } 31 | -------------------------------------------------------------------------------- /src/renderer/src/components/tooltip.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as TooltipPrimitive from '@radix-ui/react-tooltip' 3 | 4 | import { cn } from '@renderer/lib/cn' 5 | 6 | const TooltipProvider = TooltipPrimitive.Provider 7 | 8 | const Tooltip = TooltipPrimitive.Root 9 | 10 | const TooltipTrigger = TooltipPrimitive.Trigger 11 | 12 | const TooltipContent = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, sideOffset = 4, ...props }, ref) => ( 16 | 17 | 26 | 27 | )) 28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName 29 | 30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } 31 | -------------------------------------------------------------------------------- /src/main/actions.d.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | UseMutationOptions, 3 | UseMutationResult, 4 | useMutation, 5 | UseQueryOptions, 6 | UseQueryResult 7 | } from '@tanstack/react-query' 8 | 9 | type Actions = typeof import('./actions').actions 10 | 11 | // remove the first argument of each object method 12 | export type ActionsProxy = { 13 | [K in keyof Actions]: { 14 | invoke: Actions[K] extends (context: any, input: infer P) => Promise 15 | ? (input: P) => Promise 16 | : never 17 | 18 | useMutation: Actions[K] extends (context: any, input: infer P) => Promise 19 | ? (mutationOptions?: UseMutationOptions) => UseMutationResult 20 | : never 21 | 22 | useQuery: Actions[K] extends (context: any, input: infer P) => Promise 23 | ? ( 24 | variables: P, 25 | queryOptions?: Omit, 'queryKey'> 26 | ) => UseQueryResult 27 | : never 28 | 29 | setQueryData: Actions[K] extends (context: any, input: infer P) => Promise 30 | ? (variables: P, updater: (prev: R | undefined) => R | undefined) => R | undefined 31 | : never 32 | 33 | removeQueryCache: Actions[K] extends (context: any, input: infer P) => Promise 34 | ? () => void 35 | : never 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/shared/types.ts: -------------------------------------------------------------------------------- 1 | import type { IdentifyResult } from 'sql-query-identifier/lib/defines' 2 | import type { ModelId } from './constants' 3 | 4 | export type ConnectionType = 'sqlite' | 'postgresql' | 'mysql' 5 | 6 | export type ConnectionConfig = {} 7 | 8 | export type Connection = { 9 | id: string 10 | createdAt: Date 11 | nickname: string 12 | type: ConnectionType 13 | database: string 14 | config?: ConnectionConfig | null 15 | user?: string | null 16 | password?: string | null 17 | host?: string | null 18 | port?: string | null 19 | } 20 | 21 | export type Config = { 22 | openaiApiKey?: string 23 | openaiApiEndpoint?: string 24 | anthropicApiKey?: string 25 | anthropicApiEndpoint?: string 26 | deepseekApiKey?: string 27 | model?: ModelId 28 | } 29 | 30 | export type DatabaseColumn = { 31 | name: string 32 | type: string 33 | nullable: boolean 34 | default: string | null 35 | } 36 | 37 | export type DatabaseSchema = { 38 | tables: { 39 | name: string 40 | columns: DatabaseColumn[] 41 | }[] 42 | } 43 | 44 | export type Query = { 45 | id: string 46 | createdAt: Date 47 | connectionId: string 48 | title: string 49 | query: string 50 | } 51 | 52 | export type QueryDatabaseResult = { 53 | statement: IdentifyResult 54 | rows: Record[] 55 | rowsAffected?: number | null 56 | error?: string 57 | aborted?: boolean 58 | } 59 | -------------------------------------------------------------------------------- /src/renderer/src/components/Popover.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as PopoverPrimitive from '@radix-ui/react-popover' 3 | import { cn } from '@renderer/lib/cn' 4 | 5 | const Popover = PopoverPrimitive.Root 6 | 7 | const PopoverTrigger = PopoverPrimitive.Trigger 8 | 9 | const PopoverAnchor = PopoverPrimitive.Anchor 10 | 11 | const PopoverContent = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef 14 | >(({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( 15 | 16 | 26 | 27 | )) 28 | PopoverContent.displayName = PopoverPrimitive.Content.displayName 29 | 30 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } 31 | -------------------------------------------------------------------------------- /src/renderer/src/components/popover.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as PopoverPrimitive from '@radix-ui/react-popover' 3 | import { cn } from '@renderer/lib/cn' 4 | 5 | const Popover = PopoverPrimitive.Root 6 | 7 | const PopoverTrigger = PopoverPrimitive.Trigger 8 | 9 | const PopoverAnchor = PopoverPrimitive.Anchor 10 | 11 | const PopoverContent = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef 14 | >(({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( 15 | 16 | 26 | 27 | )) 28 | PopoverContent.displayName = PopoverPrimitive.Content.displayName 29 | 30 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } 31 | -------------------------------------------------------------------------------- /src/renderer/src/css/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --font-mono: JetBrains Mono Variable, ui-monospace; 7 | --editor-wrapper-height: 400px; 8 | 9 | --popover-bg: theme('colors.white'); 10 | --popover-fg: theme('colors.black'); 11 | 12 | --modal-bg: white; 13 | --modal-overlay-bg: rgba(220, 220, 220, 0.4); 14 | --modal-shadow: 0px 1.8px 7.3px rgba(0, 0, 0, 0.071), 0px 6.3px 24.7px rgba(0, 0, 0, 0.112), 15 | 0px 30px 90px rgba(0, 0, 0, 0.2); 16 | --modal-border-color: #bdbdbd; 17 | } 18 | 19 | body { 20 | user-select: none; 21 | } 22 | 23 | .drag-region { 24 | -webkit-app-region: drag; 25 | } 26 | 27 | .no-drag-region { 28 | -webkit-app-region: no-drag; 29 | } 30 | 31 | button, 32 | [role='button'], 33 | a { 34 | cursor: default; 35 | select: none; 36 | } 37 | 38 | button, 39 | a, 40 | [role='button'], 41 | input, 42 | select, 43 | textarea, 44 | [data-radix-popper-content-wrapper] { 45 | -webkit-app-region: no-drag; 46 | } 47 | 48 | .context-menu-trigger[data-context-menu-open] { 49 | @apply ring-2 ring-inset ring-blue-500 relative; 50 | } 51 | 52 | .highlight-decoration { 53 | @apply bg-slate-100; 54 | } 55 | 56 | .react-flow__attribution { 57 | display: none; 58 | } 59 | 60 | .prose > * + * { 61 | margin-top: 1em; 62 | } 63 | 64 | .prose pre { 65 | @apply bg-slate-800 text-white p-4 rounded-lg overflow-auto whitespace-pre-wrap; 66 | } 67 | -------------------------------------------------------------------------------- /src/renderer/src/lib/database.ts: -------------------------------------------------------------------------------- 1 | import { ConnectionType, DatabaseSchema } from '@shared/types' 2 | import { identify } from 'sql-query-identifier' 3 | 4 | export const databaseSchemaToSQL = (type: ConnectionType, schema: DatabaseSchema | undefined) => { 5 | if (!schema) return '' 6 | 7 | const quote = (input: string) => { 8 | const char = type === 'mysql' ? '`' : '"' 9 | return `${char}${input}${char}` 10 | } 11 | 12 | const tables = schema.tables.map((table) => { 13 | const columns = table.columns.map((column) => { 14 | return `${quote(column.name)} ${column.type}${column.nullable ? '' : ' NOT NULL'}` 15 | }) 16 | 17 | return `CREATE TABLE ${quote(table.name)} (\n ${columns.join(',\n ')}\n);` 18 | }) 19 | 20 | return tables.join('\n') 21 | } 22 | 23 | export const formatConnectionType = (type: ConnectionType) => { 24 | switch (type) { 25 | case 'sqlite': 26 | return 'SQLite' 27 | case 'postgresql': 28 | return 'PostgreSQL' 29 | case 'mysql': 30 | return 'MySQL' 31 | default: 32 | return type 33 | } 34 | } 35 | 36 | export const connectionTypes = ['sqlite', 'postgresql', 'mysql'] as const 37 | 38 | export const getConnectionDefaultValues = () => { 39 | return { 40 | host: '', 41 | port: '', 42 | user: '', 43 | password: '', 44 | database: '' 45 | } 46 | } 47 | 48 | export const identifyQueryQuiet = (query: string) => { 49 | try { 50 | return identify(query) 51 | } catch { 52 | return [] 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /electron-builder.yml: -------------------------------------------------------------------------------- 1 | appId: com.electron.app 2 | productName: querybase 3 | directories: 4 | buildResources: build 5 | files: 6 | - '!**/.vscode/*' 7 | - '!src/*' 8 | - '!electron.vite.config.{js,ts,mjs,cjs}' 9 | - '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}' 10 | - '!{.env,.env.*,.npmrc,pnpm-lock.yaml}' 11 | - '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}' 12 | asarUnpack: 13 | - resources/** 14 | win: 15 | executableName: querybase 16 | nsis: 17 | artifactName: ${name}-${version}-setup.${ext} 18 | shortcutName: ${productName} 19 | uninstallDisplayName: ${productName} 20 | createDesktopShortcut: always 21 | mac: 22 | entitlementsInherit: build/entitlements.mac.plist 23 | extendInfo: 24 | - NSCameraUsageDescription: Application requests access to the device's camera. 25 | - NSMicrophoneUsageDescription: Application requests access to the device's microphone. 26 | - NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder. 27 | - NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder. 28 | notarize: false 29 | dmg: 30 | artifactName: ${name}-${version}.${ext} 31 | linux: 32 | target: 33 | - AppImage 34 | - snap 35 | - deb 36 | maintainer: electronjs.org 37 | category: Utility 38 | appImage: 39 | artifactName: ${name}-${version}.${ext} 40 | npmRebuild: false 41 | publish: 42 | provider: generic 43 | url: https://example.com/auto-updates 44 | electronDownload: 45 | mirror: https://npmmirror.com/mirrors/electron/ 46 | -------------------------------------------------------------------------------- /src/main/cors.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow } from 'electron' 2 | 3 | export function disableCors(window: BrowserWindow) { 4 | function upsertKeyValue( 5 | obj: Record, 6 | keyToChange: string, 7 | value: string[] 8 | ) { 9 | const keyToChangeLower = keyToChange.toLowerCase() 10 | for (const key of Object.keys(obj)) { 11 | if (key.toLowerCase() === keyToChangeLower) { 12 | // Reassign old key 13 | obj[key] = value 14 | // Done 15 | return 16 | } 17 | } 18 | // Insert at end instead 19 | obj[keyToChange] = value 20 | } 21 | 22 | window.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => { 23 | const { requestHeaders, url } = details 24 | upsertKeyValue(requestHeaders, 'Access-Control-Allow-Origin', ['*']) 25 | 26 | const { protocol, host } = new URL(url) 27 | upsertKeyValue(requestHeaders, 'Origin', [`${protocol}//${host}`]) 28 | 29 | callback({ requestHeaders }) 30 | }) 31 | 32 | window.webContents.session.webRequest.onHeadersReceived((details, callback) => { 33 | const responseHeaders = details.responseHeaders || {} 34 | upsertKeyValue(responseHeaders, 'Access-Control-Allow-Origin', ['*']) 35 | upsertKeyValue(responseHeaders, 'Access-Control-Allow-Headers', ['*']) 36 | 37 | if (details.method === 'OPTIONS') { 38 | details.statusCode = 200 39 | details.statusLine = 'HTTP/1.1 200 OK' 40 | details.responseHeaders = responseHeaders 41 | return callback(details) 42 | } 43 | 44 | callback({ 45 | responseHeaders 46 | }) 47 | }) 48 | } 49 | -------------------------------------------------------------------------------- /src/renderer/src/lib/native-menu.ts: -------------------------------------------------------------------------------- 1 | import { actionsProxy } from './actions-proxy' 2 | 3 | export const showNativeMenu = async ( 4 | _items: Array<{ type: 'text'; label: string; click: () => void } | { type: 'separator' }>, 5 | e?: MouseEvent | React.MouseEvent 6 | ) => { 7 | const items = [ 8 | ..._items, 9 | ...(import.meta.env.DEV && e 10 | ? [ 11 | { 12 | type: 'separator' as const 13 | }, 14 | { 15 | type: 'text' as const, 16 | label: 'Inspect Element', 17 | click: () => { 18 | actionsProxy.inspectElement.invoke({ 19 | x: e.pageX, 20 | y: e.pageY 21 | }) 22 | } 23 | } 24 | ] 25 | : []) 26 | ] 27 | 28 | const el = e && e.currentTarget 29 | 30 | if (el instanceof HTMLElement) { 31 | el.setAttribute('data-context-menu-open', 'true') 32 | } 33 | 34 | const unlisten = window.electron.ipcRenderer.on('menu-click', (_, index) => { 35 | const item = items[index] 36 | if (item && item.type === 'text') { 37 | item.click() 38 | } 39 | }) 40 | 41 | window.electron.ipcRenderer.once('menu-closed', () => { 42 | unlisten() 43 | if (el instanceof HTMLElement) { 44 | el.removeAttribute('data-context-menu-open') 45 | } 46 | }) 47 | 48 | await actionsProxy.showContextMenu.invoke({ 49 | items: items.map((item) => { 50 | if (item.type === 'text') { 51 | return { 52 | ...item, 53 | click: undefined 54 | } 55 | } 56 | 57 | return item 58 | }) 59 | }) 60 | } 61 | -------------------------------------------------------------------------------- /src/main/serve.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import fs from 'fs/promises' 3 | import { ProtocolRequest, ProtocolResponse, protocol } from 'electron' 4 | 5 | const rendererDir = path.join(__dirname, '../renderer') 6 | 7 | // See https://cs.chromium.org/chromium/src/net/base/net_error_list.h 8 | const FILE_NOT_FOUND = -6 9 | 10 | const getPath = async (path_: string): Promise => { 11 | try { 12 | const result = await fs.stat(path_) 13 | 14 | if (result.isFile()) { 15 | return path_ 16 | } 17 | 18 | if (result.isDirectory()) { 19 | return getPath(path.join(path_, 'index.html')) 20 | } 21 | } catch (_) {} 22 | 23 | return null 24 | } 25 | 26 | const handleApp = async ( 27 | request: ProtocolRequest, 28 | callback: (response: string | ProtocolResponse) => void 29 | ) => { 30 | const indexPath = path.join(rendererDir, 'index.html') 31 | const filePath = path.join(rendererDir, decodeURIComponent(new URL(request.url).pathname)) 32 | const resolvedPath = (await getPath(filePath)) || (await getPath(filePath + '.html')) 33 | const fileExtension = path.extname(filePath) 34 | 35 | if (resolvedPath || !fileExtension || fileExtension === '.html' || fileExtension === '.asar') { 36 | callback({ 37 | path: resolvedPath || indexPath 38 | }) 39 | } else { 40 | callback({ error: FILE_NOT_FOUND }) 41 | } 42 | } 43 | 44 | export const registerAssetsProtocol = () => { 45 | protocol.registerFileProtocol('assets', (request, callback) => { 46 | const { host, searchParams } = new URL(request.url) 47 | 48 | if (host === 'file') { 49 | const filepath = searchParams.get('path') 50 | if (filepath) { 51 | return callback({ path: filepath }) 52 | } 53 | } 54 | if (host === 'app') { 55 | return handleApp(request, callback) 56 | } 57 | 58 | callback({ error: FILE_NOT_FOUND }) 59 | }) 60 | } 61 | -------------------------------------------------------------------------------- /src/renderer/src/main.tsx: -------------------------------------------------------------------------------- 1 | import './css/tailwind.css' 2 | import './css/spinner.css' 3 | import '@fontsource-variable/jetbrains-mono' 4 | 5 | import React from 'react' 6 | import ReactDOM from 'react-dom/client' 7 | import { RouterProvider, createBrowserRouter } from 'react-router-dom' 8 | import App from './App' 9 | import { QueryClientProvider } from '@tanstack/react-query' 10 | import { queryClient } from './lib/query-client' 11 | import { TooltipProvider } from './components/tooltip' 12 | import { showNativeMenu } from './lib/native-menu' 13 | import { actionsProxy } from './lib/actions-proxy' 14 | 15 | const router = createBrowserRouter([ 16 | { 17 | path: '/', 18 | element: , 19 | children: [ 20 | { 21 | path: 'connections/:id', 22 | lazy: () => import('./pages/connections/[id]'), 23 | children: [ 24 | { path: 'schema', lazy: () => import('./pages/connections/[id]/schema') }, 25 | { 26 | path: 'queries/:queryId', 27 | lazy: () => import('./pages/connections/[id]/queries/[queryId]') 28 | } 29 | ] 30 | }, 31 | { 32 | path: 'updater', 33 | lazy: () => import('./pages/updater') 34 | }, 35 | { 36 | path: '', 37 | lazy: () => import('./pages/index') 38 | } 39 | ] 40 | } 41 | ]) 42 | 43 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | ) 52 | 53 | document.addEventListener('contextmenu', (e) => { 54 | e.preventDefault() 55 | showNativeMenu([], e) 56 | }) 57 | 58 | if (location.pathname === '/') { 59 | actionsProxy.checkForUpdates.invoke() 60 | } 61 | -------------------------------------------------------------------------------- /src/renderer/src/components/database-flow.tsx: -------------------------------------------------------------------------------- 1 | import { DatabaseSchema } from '@shared/types' 2 | import { UITooltip } from './ui-tooltip' 3 | 4 | export const DatabaseFlow = ({ schema }: { schema: DatabaseSchema }) => { 5 | return ( 6 |
7 | {schema.tables.length > 0 ? ( 8 |
9 | {schema.tables.map((table) => { 10 | return ( 11 |
12 |
13 | {table.name} 14 |
15 |
16 | {table.columns.map((column) => { 17 | return ( 18 |
19 | {column.name} 20 | {column.type} 21 | {!column.nullable && ( 22 | 23 | 24 | 25 | )} 26 |
27 | ) 28 | })} 29 |
30 |
31 | ) 32 | })} 33 |
34 | ) : ( 35 |
36 |
37 | 38 | No tables found 39 |
40 |
41 | )} 42 |
43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /src/renderer/src/components/Input.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@renderer/lib/cn' 2 | import { forwardRef, useState } from 'react' 3 | import { tv } from 'tailwind-variants' 4 | 5 | const inputVariants = tv({ 6 | base: 'h-8 rounded-md px-3 flex text-sm items-center outline-none border focus:ring-1 ring-blue-500 focus:border-blue-500', 7 | variants: { 8 | isTextarea: { 9 | true: 'h-auto py-2 px-2 w-full resize-none' 10 | }, 11 | isPassword: { 12 | true: 'pr-8' 13 | } 14 | } 15 | }) 16 | 17 | export const Input = forwardRef< 18 | HTMLInputElement, 19 | React.InputHTMLAttributes & { 20 | classNames?: { 21 | wrapper?: string 22 | } 23 | endContent?: React.ReactNode 24 | startContent?: React.ReactNode 25 | } 26 | >(({ className, classNames, startContent, endContent, ...props }, ref) => { 27 | const [revealPassword, setRevealPassword] = useState(false) 28 | 29 | return ( 30 |
31 | {startContent} 32 | 38 | {props.type === 'password' && ( 39 | 48 | )} 49 | {endContent} 50 |
51 | ) 52 | }) 53 | 54 | export const Textarea = forwardRef< 55 | HTMLTextAreaElement, 56 | React.TextareaHTMLAttributes 57 | >(({ className, ...props }, ref) => ( 58 |
59 |