├── .gitignore ├── .gitmodules ├── copyMonaco.js ├── fuse.js ├── package-lock.json ├── package.json ├── readme.md ├── screenshots ├── datatable.png ├── query.png ├── schema.png └── settings.png └── src ├── assets └── icons │ ├── add.svg │ ├── chevron-right.svg │ ├── close-panel.svg │ ├── close.svg │ ├── database.svg │ ├── dev-reload.svg │ ├── dev-tool.svg │ ├── disconnect.svg │ ├── error.svg │ ├── execute.svg │ ├── folder.svg │ ├── layout-vert.svg │ ├── link.svg │ ├── module.svg │ ├── multilink.svg │ ├── open-panel.svg │ ├── pin.svg │ ├── query.svg │ ├── refresh.svg │ ├── required.svg │ ├── run.svg │ ├── schema.svg │ ├── server.svg │ ├── settings-alt.svg │ ├── settings.svg │ ├── sort-asc.svg │ ├── sort.svg │ └── table.svg ├── main ├── connectionsManager.ts ├── dev.ts ├── dumpRestore.ts ├── edgedbTypes.ts ├── main.ts ├── queryHandler.ts ├── queuedConnection.ts ├── sshTunnel.ts ├── sysPref.ts └── windowControl.ts ├── monaco ├── init.ts ├── languageDef.ts ├── symbols.ts └── themeData.ts ├── preload.ts ├── renderer ├── App.vue ├── components │ ├── ConnectionsPanel.vue │ ├── MainPanel.vue │ ├── ModuleProvider.vue │ ├── QueryErrorView.vue │ ├── ResultsTreeView.vue │ ├── SchemaGraph.vue │ ├── StatusBar.vue │ ├── TabBar.vue │ ├── WindowControls.vue │ ├── connections │ │ ├── ConnectionItem.vue │ │ ├── DatabaseItem.vue │ │ ├── ModuleItem.vue │ │ └── TreeViewItem.vue │ ├── dataTable │ │ └── RenderColumns.vue │ ├── dataViewers │ │ ├── BytesViewer.vue │ │ ├── DataViewer.ts │ │ ├── EnumViewer.vue │ │ ├── JsonViewer.vue │ │ └── StrViewer.vue │ ├── renderers │ │ ├── ItemType.ts │ │ └── ItemValue.ts │ ├── schemaView │ │ ├── DetailsItem.vue │ │ ├── SchemaDetails.vue │ │ ├── SchemaLink.vue │ │ ├── SchemaTable.vue │ │ └── SchemaTableItem.vue │ ├── settings │ │ ├── ConnectionPage.vue │ │ ├── DumpPage.vue │ │ └── RestorePage.vue │ └── tabViews │ │ ├── ConnSettingsView.vue │ │ ├── DataView.vue │ │ ├── QueryView.vue │ │ └── SchemaView.vue ├── index.ts ├── renderer.html ├── schemaLayout │ └── runLayout.ts ├── store │ ├── connections.ts │ ├── resizablePanel.ts │ ├── store.ts │ ├── tabs.ts │ ├── tabs │ │ ├── connSettings.ts │ │ ├── data.ts │ │ ├── query.ts │ │ └── schema.ts │ ├── typesResolver.ts │ ├── viewerSettings.ts │ └── window.ts ├── ui │ ├── Button.vue │ ├── FilePicker.vue │ ├── Icon.vue │ ├── Loading.vue │ ├── Progress.vue │ ├── ResizablePanel.vue │ └── Select.vue └── utils │ ├── edgedb.ts │ ├── ipc.ts │ ├── jsonParser.ts │ ├── misc.ts │ └── query.ts ├── shared └── interfaces.ts ├── svgsymbolsplugin ├── helper.ts └── plugin.js ├── tsconfig.json └── workers └── schemaLayout ├── aStar.ts ├── layout.ts └── routeLinks.ts /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .fusebox 3 | .vscode 4 | dist 5 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "src/edgedb"] 2 | path = src/edgedb 3 | url = https://github.com/jaclarke/edgedb-js.git 4 | branch = electron-client 5 | -------------------------------------------------------------------------------- /copyMonaco.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const { readFileSync, writeFileSync } = require('fs') 3 | const cpy = require('cpy') 4 | 5 | ;(async () => { 6 | await cpy('./{editor,base}/**', '../../../../dist/monaco/vs/', { 7 | cwd: './node_modules/monaco-editor/min/vs/', 8 | parents: true 9 | }) 10 | 11 | const requirejs = readFileSync('./node_modules/requirejs/require.js', 'utf8') 12 | writeFileSync('./dist/monaco/require.js', 13 | `var _monaco = (function () { 14 | //Define a require object here that has any 15 | //default configuration you want for RequireJS. If 16 | //you do not have any config options you want to set, 17 | //just use an simple object literal, {}. You may need 18 | //to at least set baseUrl. 19 | var require = {}; 20 | 21 | ${ requirejs } 22 | 23 | return {require, define}; 24 | }());` 25 | ) 26 | 27 | console.log('Files copied!') 28 | })() 29 | -------------------------------------------------------------------------------- /fuse.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const { FuseBox, CSSPlugin, VueComponentPlugin, WebIndexPlugin, JSONPlugin } = require('fuse-box') 3 | const execa = require('execa') 4 | const { SVGSymbolsPlugin } = require('./src/svgsymbolsplugin/plugin') 5 | 6 | const fuse = FuseBox.init({ 7 | homeDir: 'src', 8 | output: 'dist/$name.js', 9 | allowSyntheticDefaultImports: true, 10 | ignoreModules: ['electron'], 11 | alias: { 12 | 'edgedb': '~/edgedb/dist/src/', 13 | '@shared': '~/shared/', 14 | '@store': '~/renderer/store/', 15 | '@components': '~/renderer/components/', 16 | '@utils': '~/renderer/utils/', 17 | '@ui': '~/renderer/ui/', 18 | }, 19 | plugins: [ 20 | WebIndexPlugin({ 21 | bundles: ['renderer'], 22 | target: 'renderer.html', 23 | template: 'src/renderer/renderer.html', 24 | path: '.', 25 | }), 26 | CSSPlugin(), 27 | JSONPlugin(), 28 | SVGSymbolsPlugin(), 29 | ] 30 | }) 31 | 32 | 33 | fuse.bundle('main') 34 | .target('electron') 35 | .instructions('>main/main.ts -electron-devtools-installer') 36 | 37 | fuse.bundle('preload') 38 | .target('electron') 39 | .instructions('>preload.ts') 40 | 41 | fuse.dev({ 42 | httpServer: false 43 | }) 44 | fuse.bundle('renderer') 45 | .target('browser') 46 | .plugin(VueComponentPlugin()) 47 | .instructions('>renderer/index.ts') 48 | .hmr() 49 | .watch() 50 | 51 | fuse.bundle('schemaLayoutWorker') 52 | .target('browser') 53 | .instructions('>workers/schemaLayout/layout.ts') 54 | 55 | fuse.run() 56 | .then(() => { 57 | const child = execa("node", [`${ __dirname }/node_modules/electron/cli.js`, 'dist/main.js'], { stdio: "inherit" }) 58 | .on("close", () => process.exit()) 59 | .on('data', (data) => console.log("electron > " + data)) 60 | }) 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "edgedb-ui", 3 | "version": "0.0.1", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "postinstall": "node copyMonaco.js", 8 | "start": "node fuse" 9 | }, 10 | "author": "", 11 | "license": "ISC", 12 | "devDependencies": { 13 | "@types/lodash": "^4.14.149", 14 | "@types/nanoid": "^2.1.0", 15 | "@types/ssh2": "^0.5.42", 16 | "cpy": "^8.0.0", 17 | "csv-parse": "^4.8.5", 18 | "electron": "^8.0.1", 19 | "electron-better-ipc": "github:jaclarke/electron-better-ipc", 20 | "electron-devtools-installer": "^2.2.4", 21 | "electron-window-state": "^5.0.3", 22 | "execa": "^4.0.0", 23 | "fastpriorityqueue": "^0.6.3", 24 | "fuse-box": "^3.7.1", 25 | "lodash": "^4.17.15", 26 | "nanoid": "^2.1.8", 27 | "portal-vue": "^2.1.7", 28 | "postcss-selector-parser": "^6.0.2", 29 | "promise-stream-reader": "^1.0.1", 30 | "requirejs": "^2.3.6", 31 | "ssh2": "^0.8.9", 32 | "stylus": "^0.54.7", 33 | "typescript": "^3.7.4", 34 | "vue": "^2.6.11", 35 | "vue-hot-reload-api": "^2.3.4", 36 | "vue-monaco": "^1.1.0", 37 | "vue-property-decorator": "^8.3.0", 38 | "vue-template-compiler": "^2.6.11", 39 | "vue-template-es2015-compiler": "^1.9.1", 40 | "vue-virtual-scroller": "^1.0.10", 41 | "vuelidate": "^0.7.5", 42 | "vuex": "^3.1.2", 43 | "vuex-class-modules": "^1.1.2", 44 | "webcola": "^3.4.0", 45 | "write-file-atomic": "^3.0.1", 46 | "zod": "^1.2.3" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | ⚠ This project is now obsolete, and doesn't work with any post-beta version of EdgeDB. It's recommended to instead use the official EdgeDB UI. 2 | 3 | # Untitled EdgeDB GUI Client 4 | 5 | A basic EdgeDB GUI Client for Windows (It should work on any platform supported by Electron, but currently it's only designed for Windows, so the UI will look a bit out of place on other OS's). 6 | 7 | This project initally started as an experimental webapp for generating ER diagrams from EdgeDB schemas. However, I've since wrapped it up as an Electron app, and added a simple query repl and datatable view, so it should now be usable as a very basic EdgeDB client. 8 | 9 | ## Installation / Usage 10 | 11 | ``` sh 12 | git submodule update --init 13 | 14 | npm install 15 | 16 | # Build edgedb-js submodule 17 | cd src/edgedb 18 | yarn install 19 | yarn build 20 | cd ../../ 21 | 22 | # Run app 23 | npm start 24 | ``` 25 | 26 | ## Screenshots 27 | 28 | ![Query](screenshots/query.png) 29 | ![Schema ER Diagram](screenshots/schema.png) 30 | ![Datatable](screenshots/datatable.png) 31 | ![Settings](screenshots/settings.png) 32 | -------------------------------------------------------------------------------- /screenshots/datatable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaclarke/edgedb-ui/3c2370624935187266f5ff2b375261e935efd456/screenshots/datatable.png -------------------------------------------------------------------------------- /screenshots/query.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaclarke/edgedb-ui/3c2370624935187266f5ff2b375261e935efd456/screenshots/query.png -------------------------------------------------------------------------------- /screenshots/schema.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaclarke/edgedb-ui/3c2370624935187266f5ff2b375261e935efd456/screenshots/schema.png -------------------------------------------------------------------------------- /screenshots/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaclarke/edgedb-ui/3c2370624935187266f5ff2b375261e935efd456/screenshots/settings.png -------------------------------------------------------------------------------- /src/assets/icons/add.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/chevron-right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/close-panel.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/close.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/database.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/dev-reload.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/dev-tool.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/disconnect.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/error.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/execute.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/folder.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/layout-vert.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/link.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/module.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/multilink.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/open-panel.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/pin.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/query.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/refresh.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/required.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/run.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/schema.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/server.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/assets/icons/settings-alt.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/settings.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/assets/icons/sort-asc.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/sort.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/table.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/connectionsManager.ts: -------------------------------------------------------------------------------- 1 | import { app } from 'electron' 2 | import { join } from 'path' 3 | import { readFile } from 'fs' 4 | import { promisify } from 'util' 5 | import { Server, AddressInfo } from 'net' 6 | import { ipcMain as ipc } from 'electron-better-ipc' 7 | import * as edgedb from 'edgedb/index.node' 8 | import { uuid } from 'edgedb/codecs/ifaces' 9 | import LRU from 'edgedb/lru' 10 | import * as z from 'zod' 11 | import writeFileAtomic from 'write-file-atomic' 12 | import { createTunnel } from './sshTunnel' 13 | import { QueuedConnection } from './queuedConnection' 14 | import { ConnectionParser, ConnectionConfig } from '@shared/interfaces' 15 | 16 | const readFileAsync = promisify(readFile) 17 | 18 | const ConnectionsParser = z.array(ConnectionParser) 19 | 20 | const codecsTypeData = new LRU({capacity: 1000}) 21 | const codecRegistry = new edgedb._CodecsRegistry(codecsTypeData) 22 | 23 | interface Connection { 24 | connection: QueuedConnection 25 | serverVersion: string 26 | sshTunnel?: SSHTunnel 27 | } 28 | 29 | interface SSHTunnel { 30 | server: Server 31 | host: string 32 | port: number 33 | connections: Set 34 | } 35 | 36 | let connectionsStorePath: string 37 | 38 | const connectionConfigs = new Map(), 39 | connections = new Map(), 40 | sshTunnels = new Map() 41 | 42 | async function saveConnectionConfigs() { 43 | const data = JSON.stringify([...connectionConfigs.values()]) 44 | await writeFileAtomic(connectionsStorePath, data) 45 | } 46 | 47 | async function setupSSHTunnel(config: ConnectionConfig) { 48 | const sshAuth = (config.ssh.auth.type === 'key') ? { 49 | privateKey: await readFileAsync(config.ssh.auth.keyFile), 50 | passphrase: config.ssh.auth.passphrase 51 | } : { 52 | password: config.ssh.auth.password 53 | } 54 | const server = await createTunnel({ 55 | host: config.ssh.host, 56 | port: config.ssh.port || 22, 57 | dstHost: config.host, 58 | dstPort: config.port || 5656, 59 | localHost: '127.0.0.1', 60 | localPort: 0, 61 | keepAlive: true, 62 | username: config.ssh.user, 63 | ...sshAuth 64 | }) 65 | const tunnel: SSHTunnel = { 66 | server, 67 | host: '127.0.0.1', 68 | port: (server.address() as AddressInfo).port, 69 | connections: new Set(), 70 | } 71 | sshTunnels.set(config.id, tunnel) 72 | return tunnel 73 | } 74 | 75 | async function setupConnection(config: ConnectionConfig, database: string, background = false) { 76 | const connectionInfo = { 77 | configId: config.id, 78 | database: database 79 | } 80 | 81 | if (!background) ipc.sendToRenderers('connections:connectionOpening', connectionInfo) 82 | 83 | try { 84 | let tunnel: SSHTunnel 85 | if (config.ssh) { 86 | tunnel = sshTunnels.get(config.id) || await setupSSHTunnel(config) 87 | } 88 | const rawConn = await edgedb.connect({ 89 | host: tunnel ? tunnel.host : config.host, 90 | port: tunnel ? tunnel.port : ( config.port || 5656 ), 91 | user: config.user, 92 | password: config.password, 93 | database: connectionInfo.database, 94 | _codecRegistry: codecRegistry, 95 | }) 96 | 97 | const serverVersion: string = await rawConn.fetchOne('SELECT sys::get_version_as_str()') 98 | const conn: Connection = { 99 | connection: new QueuedConnection(rawConn), 100 | serverVersion, 101 | sshTunnel: tunnel 102 | } 103 | if (tunnel) tunnel.connections.add(conn) 104 | 105 | conn.connection.conn.on('close', () => { 106 | if (!background) connections.delete(`${config.id}:${connectionInfo.database}`) 107 | if (conn.sshTunnel) { 108 | conn.sshTunnel.connections.delete(conn) 109 | } 110 | if (conn.sshTunnel?.connections.size === 0) { 111 | sshTunnels.delete(config.id) 112 | conn.sshTunnel.server.close() 113 | } 114 | if (!background) ipc.sendToRenderers('connections:connectionClosed', connectionInfo) 115 | }).on('transactionStateChanged', (state) => { 116 | if (!background) ipc.sendToRenderers('connections:transactionStateChanged', { 117 | ...connectionInfo, 118 | state 119 | }) 120 | }) 121 | 122 | if (!background) { 123 | connections.set(`${config.id}:${connectionInfo.database}`, conn) 124 | ipc.sendToRenderers('connections:connectionOpened', {...connectionInfo, serverVersion}) 125 | } 126 | 127 | return conn 128 | 129 | } catch(error) { 130 | if (!background) ipc.sendToRenderers('connections:connectionClosed', connectionInfo) 131 | 132 | throw error 133 | } 134 | } 135 | 136 | export async function getConnection(connId: string, database?: string, background = false) { 137 | const config = connectionConfigs.get(connId) 138 | if (!config) throw new Error('Invalid Connection Id') 139 | 140 | const db = database || 141 | [...connections.keys()].find(key => key.split(':')[0] === connId)?.split(':')[1] || 142 | config.defaultDatabase || 143 | config.user 144 | 145 | let conn: Connection 146 | 147 | if (!background) { 148 | conn = connections.get(`${connId}:${db}`) 149 | } 150 | 151 | if (!conn) conn = await setupConnection(config, db, background) 152 | 153 | return conn.connection 154 | } 155 | 156 | async function closeConnection(connId: string, database: string) { 157 | let conn = connections.get(`${connId}:${database}`) 158 | if (!conn) return; 159 | 160 | await conn.connection.close() 161 | } 162 | 163 | function getConnectionsSummary() { 164 | const connected = [...connections.keys()].map(key => key.split(':')) 165 | return [...connectionConfigs.values()].map(({id, name}) => ({ 166 | id, 167 | name, 168 | connected: connected.filter(key => key[0] === id).map(key => key[1]), 169 | serverVersion: [...connections.entries()] 170 | .find(([key]) => key.startsWith(id+':'))?.[1].serverVersion 171 | })) 172 | } 173 | 174 | app.on('ready', async () => { 175 | connectionsStorePath = join(app.getPath('userData'), 'connections.json') 176 | 177 | try { 178 | let fileData = ConnectionsParser.parse( 179 | JSON.parse( 180 | await readFileAsync(connectionsStorePath, 'utf8') 181 | ) 182 | ) 183 | 184 | fileData.forEach(config => { 185 | connectionConfigs.set(config.id, config) 186 | }) 187 | 188 | } catch (err) { 189 | console.log(err) 190 | // await saveConnectionConfigs() 191 | } 192 | }) 193 | 194 | ipc.answerRenderer('connections:getConnections', () => { 195 | return getConnectionsSummary() 196 | }) 197 | 198 | ipc.answerRenderer('connections:closeConnection', 199 | ({connectionId, database}: {connectionId: string, database: string}) => { 200 | closeConnection(connectionId, database) 201 | } 202 | ) 203 | 204 | ipc.answerRenderer('connections:getConnectionConfig', 205 | (connectionId: string) => { 206 | return connectionConfigs.get(connectionId) 207 | } 208 | ) 209 | 210 | ipc.answerRenderer('connections:testAndSaveConfig', 211 | async (config: ConnectionConfig) => { 212 | const {connection, serverVersion} = await setupConnection(config, config.defaultDatabase || config.user, true) 213 | 214 | await connection.close() 215 | 216 | connectionConfigs.set(config.id, config) 217 | await saveConnectionConfigs() 218 | 219 | ipc.sendToRenderers('connections:configsUpdated', getConnectionsSummary()) 220 | 221 | return serverVersion 222 | } 223 | ) 224 | 225 | ipc.answerRenderer('connections:getTypeData', (typeId: string) => { 226 | return codecsTypeData.get(typeId) 227 | }) 228 | 229 | ipc.answerRenderer('connections:getTransactionState', 230 | ({connectionId, database}: {connectionId: string, database: string}) => { 231 | const conn = connections.get(`${connectionId}:${database}`) 232 | return conn?.connection.conn.serverTransactionState 233 | } 234 | ) 235 | 236 | app.on('before-quit', async (event) => { 237 | if (connections.size) { 238 | await Promise.all( 239 | [...connections.values()].map(conn => conn.connection.close()) 240 | ) 241 | app.quit() 242 | } 243 | }) 244 | -------------------------------------------------------------------------------- /src/main/dev.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow } from 'electron' 2 | import { ipcMain as ipc } from 'electron-better-ipc' 3 | 4 | ipc.answerRenderer('dev:reloadWindow', (_, browserWindow) => { 5 | (browserWindow as any as BrowserWindow).reload() 6 | }) 7 | 8 | ipc.answerRenderer('dev:toggleDevtools', (_, browserWindow) => { 9 | (browserWindow as any as BrowserWindow).webContents.toggleDevTools() 10 | }) 11 | -------------------------------------------------------------------------------- /src/main/edgedbTypes.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow } from 'electron' 2 | import { ipcMain as ipc } from 'electron-better-ipc' 3 | 4 | import { KNOWN_TYPES } from 'edgedb/codecs/consts' 5 | 6 | import { getConnection } from './connectionsManager' 7 | import { TidInfo, ResolveTypesRequest, ResolveTypesResponse } from '@shared/interfaces' 8 | 9 | const tidsInfoCache = new Map() 10 | 11 | ipc.answerRenderer('typesResolver:resolve', async (request: ResolveTypesRequest, browserWindow) => { 12 | const cached: ResolveTypesResponse['types'] = [], 13 | uncached: string[] = [] 14 | 15 | for (const id of request.tids) { 16 | if (KNOWN_TYPES.has(id)) continue; 17 | 18 | if (tidsInfoCache.has(id)) cached.push(tidsInfoCache.get(id)) 19 | else uncached.push(id) 20 | } 21 | 22 | if (cached.length) { 23 | ipc.callRenderer(browserWindow as any as BrowserWindow, 'typesResolver:resolved', { 24 | types: cached 25 | }) 26 | } 27 | 28 | if (!uncached.length) return; 29 | 30 | for (const id of uncached) { 31 | tidsInfoCache.set(id, {id, name: null}) 32 | } 33 | 34 | const conn = await getConnection(request.connectionId, request.database) 35 | 36 | const result: TidInfo[] = (await conn.fetchAll(`SELECT schema::Object { 37 | id, 38 | name, 39 | } 40 | FILTER .id IN array_unpack(>$tids)`, { 41 | tids: uncached 42 | })).map(res => ({ 43 | id: res.id.toRawString(), 44 | name: res.name, 45 | })) 46 | 47 | for (const res of result) { 48 | tidsInfoCache.set(res.id, res) 49 | } 50 | 51 | ipc.callRenderer(browserWindow as any as BrowserWindow, 'typesResolver:resolved', { 52 | types: result 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /src/main/main.ts: -------------------------------------------------------------------------------- 1 | import {app, BrowserWindow, systemPreferences} from 'electron' 2 | import * as path from 'path' 3 | import installExtension, { VUEJS_DEVTOOLS } from 'electron-devtools-installer' 4 | import windowStateKeeper from 'electron-window-state' 5 | 6 | import { manageWindowControl } from './windowControl' 7 | import './connectionsManager' 8 | import './queryHandler' 9 | import './edgedbTypes' 10 | import './dumpRestore' 11 | import './sysPref' 12 | import './dev' 13 | 14 | let mainWindow: BrowserWindow | null = null 15 | 16 | function createWindow () { 17 | const mainWindowState = windowStateKeeper({ 18 | defaultWidth: 1000, 19 | defaultHeight: 700 20 | }) 21 | 22 | mainWindow = new BrowserWindow({ 23 | x: mainWindowState.x, 24 | y: mainWindowState.y, 25 | width: mainWindowState.width, 26 | height: mainWindowState.height, 27 | backgroundColor: '#333', 28 | frame: false, 29 | webPreferences: { 30 | preload: path.resolve('./dist/preload.js') 31 | } 32 | }) 33 | 34 | mainWindowState.manage(mainWindow) 35 | manageWindowControl(mainWindow) 36 | 37 | mainWindow.loadFile('./renderer.html') 38 | 39 | // mainWindow.webContents.openDevTools() 40 | 41 | mainWindow.on('closed', function () { 42 | mainWindow = null 43 | }) 44 | } 45 | 46 | app.on('ready', () => { 47 | installExtension(VUEJS_DEVTOOLS) 48 | // .then((name) => console.log(`Added Extension: ${name}`)) 49 | .catch((err) => console.log('An error occurred: ', err)) 50 | 51 | createWindow() 52 | }) 53 | 54 | app.on('window-all-closed', function () { 55 | if (process.platform !== 'darwin') app.quit() 56 | }) 57 | 58 | app.on('activate', function () { 59 | if (mainWindow === null) createWindow() 60 | }) 61 | -------------------------------------------------------------------------------- /src/main/queryHandler.ts: -------------------------------------------------------------------------------- 1 | import { ipcMain as ipc } from 'electron-better-ipc' 2 | 3 | import { getConnection } from './connectionsManager' 4 | import { Query, QueryResponse, RawQueryResponse } from '@shared/interfaces' 5 | 6 | ipc.answerRenderer('query:runRaw', async (query: Query): Promise => { 7 | const conn = await getConnection(query.connectionId, query.database) 8 | 9 | try { 10 | const queryStartTime = Date.now(), 11 | result = await conn.fetchAllRaw(query.query, query.args, { 12 | implicitLimit: query.limit 13 | }), 14 | time = Date.now() - queryStartTime 15 | 16 | return { 17 | time, 18 | result, 19 | status: conn.conn['lastStatus'] 20 | } 21 | } catch (error) { 22 | handleError(error) 23 | } 24 | }) 25 | 26 | ipc.answerRenderer('query:run', async (query: Query): Promise => { 27 | const conn = await getConnection(query.connectionId, query.database) 28 | 29 | try { 30 | const queryStartTime = Date.now(), 31 | result = await conn.fetchAll(query.query, query.args, { 32 | implicitLimit: query.limit 33 | }), 34 | time = Date.now() - queryStartTime 35 | 36 | return { 37 | time, 38 | result, 39 | status: conn.conn['lastStatus'] 40 | } 41 | } catch (error) { 42 | handleError(error) 43 | } 44 | }) 45 | 46 | function handleError(error: any) { 47 | const errorNames = [] 48 | let e = error 49 | while (e = Object.getPrototypeOf(e)) { 50 | const name = e.constructor.name 51 | if (name === 'Object') break; 52 | errorNames.push(name) 53 | } 54 | if (error.attrs) { 55 | error.attrs = [...error.attrs.entries()].map(([key, val]) => { 56 | return [key, val.toString()] 57 | }) 58 | } 59 | error.errorNames = errorNames 60 | throw error 61 | } 62 | -------------------------------------------------------------------------------- /src/main/queuedConnection.ts: -------------------------------------------------------------------------------- 1 | import { AwaitConnection } from 'edgedb/client' 2 | 3 | class PendingPromise { 4 | promise: Promise 5 | resolve?: (value?: T | PromiseLike) => void 6 | reject?: (reason?: any) => void 7 | constructor() { 8 | this.promise = new Promise((resolve, reject) => { 9 | this.resolve = resolve 10 | this.reject = reject 11 | }) 12 | } 13 | } 14 | 15 | export class QueuedConnection { 16 | private queue: {pending: PendingPromise, task: () => Promise}[] = [] 17 | 18 | constructor(public conn: AwaitConnection) {} 19 | 20 | private _whenConnIdle(task: () => Promise) { 21 | const pending = new PendingPromise() 22 | this.queue.push({pending, task}) 23 | 24 | if (this.queue.length === 1) this._processQueue() 25 | 26 | return pending.promise 27 | } 28 | 29 | private async _processQueue() { 30 | if (this.queue.length) { 31 | const {pending, task} = this.queue[0] 32 | 33 | try { 34 | pending.resolve( await task() ) 35 | } catch (e) { 36 | pending.reject(e) 37 | } 38 | 39 | this.queue.shift() 40 | this._processQueue() 41 | } 42 | } 43 | 44 | execute(...args: Parameters) { 45 | return this._whenConnIdle(() => this.conn.execute(...args)) 46 | } 47 | 48 | fetchOne(...args: Parameters) { 49 | return this._whenConnIdle(() => this.conn.fetchOne(...args)) 50 | } 51 | 52 | fetchAll(...args: Parameters) { 53 | return this._whenConnIdle(() => this.conn.fetchAll(...args)) 54 | } 55 | 56 | fetchAllRaw(...args: Parameters) { 57 | return this._whenConnIdle(() => this.conn._fetchAllRaw(...args)) 58 | } 59 | 60 | fetchOneJSON(...args: Parameters) { 61 | return this._whenConnIdle(() => this.conn.fetchOneJSON(...args)) 62 | } 63 | 64 | fetchAllJSON(...args: Parameters) { 65 | return this._whenConnIdle(() => this.conn.fetchAllJSON(...args)) 66 | } 67 | 68 | close() { 69 | return this.conn.close() 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/main/sshTunnel.ts: -------------------------------------------------------------------------------- 1 | import * as net from 'net' 2 | import { Client } from 'ssh2' 3 | 4 | interface Config { 5 | username: string 6 | password?: string 7 | privateKey?: string | Buffer 8 | passphrase?: string 9 | port?: number 10 | host: string 11 | srcPort?: number 12 | srcHost?: string 13 | dstPort: number 14 | dstHost: string 15 | localHost?: string 16 | localPort?: number 17 | keepAlive?: boolean 18 | } 19 | 20 | export async function createTunnel(configArgs: Config) { 21 | const config = { 22 | ...{ 23 | port: 22, 24 | srcPort: 0, 25 | srcHost: '127.0.0.1', 26 | localHost: '127.0.0.1', 27 | localPort: configArgs.dstPort, 28 | }, 29 | ...configArgs 30 | } 31 | 32 | const sshConnection = new Client() 33 | 34 | const connections = new Set() 35 | 36 | return new Promise((resolve, reject) => { 37 | 38 | sshConnection.on('error', reject) 39 | 40 | sshConnection.on('ready', () => { 41 | sshConnection.off('error', reject) 42 | 43 | const server = net.createServer((socket) => { 44 | connections.add(socket) 45 | 46 | socket.on('error', (err) => { 47 | socket.destroy() 48 | }) 49 | 50 | socket.on('close', () => { 51 | connections.delete(socket) 52 | 53 | if (!connections.size && !config.keepAlive) { 54 | server.close() 55 | } 56 | }) 57 | 58 | sshConnection.forwardOut(config.srcHost, config.srcPort, config.dstHost, config.dstPort, (err, sshStream) => { 59 | if (err) { 60 | socket.emit('error', err) 61 | return; 62 | } 63 | if (sshStream) { 64 | sshStream.on('error', err => socket.emit('error', err)) 65 | socket.on('close', () => { 66 | sshStream.close() 67 | }) 68 | sshStream.on('close', () => { 69 | socket.end() 70 | }) 71 | socket.pipe(sshStream).pipe(socket) 72 | } 73 | }) 74 | }) 75 | 76 | sshConnection.on('end', () => { 77 | server.close() 78 | }) 79 | 80 | sshConnection.on('error', (err) => { 81 | server.emit('error', err) 82 | }) 83 | 84 | server.on('close', () => { 85 | sshConnection.end() 86 | }) 87 | 88 | server.on('error', (err) => { 89 | server.close() 90 | }) 91 | 92 | server.on('error', reject) 93 | 94 | server.listen(config.localPort, config.localPort, () => { 95 | server.off('error', reject) 96 | 97 | resolve(server) 98 | }) 99 | }) 100 | 101 | try { 102 | sshConnection.connect({ 103 | host: config.host, 104 | port: config.port, 105 | username: config.username, 106 | password: config.password, 107 | privateKey: config.privateKey, 108 | passphrase: config.passphrase 109 | }) 110 | } catch (err) { 111 | reject(err) 112 | } 113 | 114 | }) 115 | } 116 | -------------------------------------------------------------------------------- /src/main/sysPref.ts: -------------------------------------------------------------------------------- 1 | import { systemPreferences } from 'electron' 2 | import { ipcMain as ipc } from 'electron-better-ipc' 3 | 4 | ipc.answerRenderer('sysPref:getAccentColour', () => { 5 | return systemPreferences.getAccentColor() 6 | }) 7 | 8 | systemPreferences.on('accent-color-changed', (_, newColour) => { 9 | ipc.sendToRenderers('sysPref:accentColourChanged', newColour) 10 | }) 11 | -------------------------------------------------------------------------------- /src/main/windowControl.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow } from 'electron' 2 | import { ipcMain as ipc } from 'electron-better-ipc' 3 | 4 | ipc.answerRenderer('windowControl:buttonPressed', (action: string, browserWindow) => { 5 | const window = browserWindow as any as BrowserWindow 6 | switch (action) { 7 | case 'minimise': 8 | window.minimize() 9 | break; 10 | case 'toggleMax': 11 | if (window.isMaximized()) window.unmaximize() 12 | else window.maximize() 13 | break; 14 | case 'close': 15 | window.close() 16 | break; 17 | } 18 | }) 19 | 20 | ipc.answerRenderer('windowControl:isMaximised', (_, browserWindow) => { 21 | return (browserWindow as any as BrowserWindow).isMaximized() 22 | }) 23 | 24 | export function manageWindowControl(window: BrowserWindow) { 25 | window.on('unmaximize', () => { 26 | ipc.callRenderer(window, 'windowControl:maximiseChanged', false) 27 | }) 28 | window.on('maximize', () => { 29 | ipc.callRenderer(window, 'windowControl:maximiseChanged', true) 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /src/monaco/init.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 3 | import { themeData } from './themeData' 4 | import languageDef from './languageDef' 5 | 6 | window.define = _monaco.define 7 | _monaco.require.config({ paths: { vs: './monaco/vs' }, waitSeconds: 0 }) 8 | 9 | _monaco.require(['vs/editor/editor.main'], (monaco: any) => { 10 | monaco.editor.defineTheme('edgeQL', themeData) 11 | 12 | monaco.languages.register({ id: 'edgeQL' }) 13 | 14 | monaco.languages.setLanguageConfiguration('edgeQL', { 15 | comments: { 16 | lineComment: '#' 17 | }, 18 | brackets: [ 19 | ['{', '}'], 20 | ['[', ']'], 21 | ['(', ')'] 22 | ], 23 | autoClosingPairs: [ 24 | ['{', '}'], 25 | ['[', ']'], 26 | ['(', ')'], 27 | ['\'', '\''], 28 | ['"', '"'] 29 | ], 30 | surroundingPairs: [ 31 | ['{', '}'], 32 | ['[', ']'], 33 | ['(', ')'], 34 | ['<', '>'], 35 | ['\'', '\''], 36 | ['"', '"'] 37 | ] 38 | }) 39 | 40 | monaco.languages.setMonarchTokensProvider('edgeQL', languageDef) 41 | }) 42 | -------------------------------------------------------------------------------- /src/monaco/languageDef.ts: -------------------------------------------------------------------------------- 1 | // Based on pygments highlighter definition https://github.com/edgedb/edgedb/blob/f69f6936b97f495cf0ddd3d8294ed0e00b4ddfde/edb/edgeql/pygments/__init__.py 2 | // and monarch sample code https://microsoft.github.io/monaco-editor/monarch.html 3 | 4 | // TODO: Rewrite properly (autogenerate from https://github.com/edgedb/edgedb-editor-plugin maybe?) 5 | 6 | import { 7 | reservedKeywords, 8 | unreservedKeywords, 9 | operators, 10 | navigation, 11 | typeBuiltins, 12 | moduleBuiltins, 13 | constraintBuiltins, 14 | fnBuiltins, 15 | boolLiterals, 16 | } from './symbols' 17 | 18 | function caseInsensitize(token: string) { 19 | return token.split('') 20 | .map(char => `[${char}${char.toUpperCase()}]`) 21 | .join('') 22 | } 23 | 24 | function escapeSymbols(symbols: string) { 25 | return '\\' + symbols.split('').join('\\') 26 | } 27 | 28 | const languageDef = { 29 | builtins: [...typeBuiltins, ...constraintBuiltins, ...fnBuiltins, ...moduleBuiltins], 30 | 31 | tokenizer: { 32 | root: [ 33 | [/[ \t\r\n]+/, 'whitespace'], 34 | {include: '@comments'}, 35 | {include: '@identifiers'}, 36 | [/[{}()\[\]]/, '@brackets'], 37 | [/@\w+/, 'name.decorator'], 38 | [/\$\w+/, 'name.variable'], 39 | [operators.map(escapeSymbols).join('|'), 'operator'], 40 | [navigation.map(escapeSymbols).join('|'), 'punctuation.navigation'], 41 | [/[;,]/, 'delimiter'], 42 | {include: '@numbers'}, 43 | {include: '@strings'}, 44 | ], 45 | 46 | comments: [ 47 | [/#.*$/, 'comment.singleline'], 48 | ], 49 | 50 | identifiers: [ 51 | [/[a-zA-Z_]\w*/, {cases: { 52 | '(__source__|__subject__)': 'name.builtin.pseudo', 53 | '__type__': 'name.builtin.pseudo', 54 | [`(${boolLiterals.map(caseInsensitize).join('|')})`]: 'keyword.constant', 55 | [`(${reservedKeywords.map(caseInsensitize).join('|')})`]: 'keyword.reserved', 56 | [`(${unreservedKeywords.map(caseInsensitize).join('|')})`]: 'keyword.reserved', 57 | '@builtins': 'name.builtin', 58 | '@default': 'indentifier' 59 | }} 60 | ], 61 | ], 62 | 63 | strings: [ 64 | [/r?(?['"])(\\['"]|\n|.)*?\k/, 'string'], 65 | [/(?\$(?:[A-Za-z_]\w*)?\$)(\n|.)*?\k/, 'string.other'], 66 | [/`.*?`/, 'string.backtick'], 67 | ], 68 | 69 | numbers: [ 70 | [/(?=', 294 | '<=', 295 | ':=', 296 | '//', 297 | '++', 298 | '!=', 299 | '^', 300 | '>', 301 | '=', 302 | '<', 303 | '/', 304 | '-', 305 | '+', 306 | '*', 307 | '%', 308 | ] 309 | export const navigation = [ 310 | '.>', 311 | '.<', 312 | '@', 313 | '.', 314 | ] 315 | -------------------------------------------------------------------------------- /src/monaco/themeData.ts: -------------------------------------------------------------------------------- 1 | // Token colours from examples on https://edgedb.com/ 2 | 3 | export const themeData = { 4 | base: 'vs-dark', 5 | inherit: true, 6 | colors: { 7 | 'editor.selectionBackground': '#444444', 8 | 'editor.lineHighlightBackground': '#252525', 9 | 'editorCursor.foreground': '#dfdfdf', 10 | 'editorWhitespace.foreground': '#3b3a32', 11 | }, 12 | rules: [ 13 | { 14 | token: 'source', 15 | foreground: '#dedede' 16 | }, 17 | { 18 | token: 'name.variable', 19 | foreground: '#2dd8a5' 20 | }, 21 | { 22 | token: 'operator', 23 | foreground: '#fb9256' 24 | }, 25 | { 26 | token: 'comment', 27 | foreground: '#8b8b8f' 28 | }, 29 | { 30 | token: 'keyword.constant', 31 | foreground: '#b466ce' 32 | }, 33 | { 34 | token: 'keyword.reserved', 35 | foreground: '#ee464d' 36 | }, 37 | { 38 | token: 'name.builtin', 39 | foreground: '#2dd8a5' 40 | }, 41 | { 42 | token: 'string', 43 | foreground: '#e5df59' 44 | }, 45 | { 46 | token: 'number', 47 | foreground: '#b466ce' 48 | }, 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /src/preload.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from 'electron-better-ipc' 2 | import { SVGSymbolsInsert } from './svgsymbolsplugin/helper' 3 | import { shell, remote, OpenDialogOptions, SaveDialogOptions } from 'electron' 4 | 5 | const currentWindow = remote.getCurrentWindow() 6 | 7 | window['ipc'] = ipcRenderer 8 | window['SVGSymbolsInsert'] = SVGSymbolsInsert 9 | 10 | window['openExternalUrl'] = (url: string) => shell.openExternal(url) 11 | 12 | window['dialog'] = { 13 | showOpenDialog: (options: OpenDialogOptions) => remote.dialog.showOpenDialog(currentWindow, options), 14 | showSaveDialog: (options: SaveDialogOptions) => remote.dialog.showSaveDialog(currentWindow, options), 15 | } 16 | -------------------------------------------------------------------------------- /src/renderer/App.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 51 | 52 | 177 | -------------------------------------------------------------------------------- /src/renderer/components/ConnectionsPanel.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 44 | 45 | 84 | -------------------------------------------------------------------------------- /src/renderer/components/MainPanel.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 67 | -------------------------------------------------------------------------------- /src/renderer/components/ModuleProvider.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 19 | -------------------------------------------------------------------------------- /src/renderer/components/QueryErrorView.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 133 | 134 | 172 | -------------------------------------------------------------------------------- /src/renderer/components/ResultsTreeView.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 134 | 135 | 275 | -------------------------------------------------------------------------------- /src/renderer/components/SchemaGraph.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 94 | 95 | 154 | -------------------------------------------------------------------------------- /src/renderer/components/StatusBar.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 22 | 23 | 47 | -------------------------------------------------------------------------------- /src/renderer/components/TabBar.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 48 | 49 | 145 | -------------------------------------------------------------------------------- /src/renderer/components/WindowControls.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 55 | 56 | 95 | -------------------------------------------------------------------------------- /src/renderer/components/connections/ConnectionItem.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 61 | 62 | 72 | -------------------------------------------------------------------------------- /src/renderer/components/connections/DatabaseItem.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 90 | 91 | 116 | -------------------------------------------------------------------------------- /src/renderer/components/connections/ModuleItem.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 80 | 81 | 96 | -------------------------------------------------------------------------------- /src/renderer/components/connections/TreeViewItem.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 69 | 70 | 122 | -------------------------------------------------------------------------------- /src/renderer/components/dataTable/RenderColumns.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 58 | 59 | 93 | -------------------------------------------------------------------------------- /src/renderer/components/dataViewers/BytesViewer.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 49 | 50 | 84 | -------------------------------------------------------------------------------- /src/renderer/components/dataViewers/DataViewer.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | 3 | import { BaseScalarName, ICodec, ScalarCodec } from '@utils/edgedb' 4 | 5 | import StrViewer from './StrViewer.vue' 6 | import BytesViewer from './BytesViewer.vue' 7 | import EnumViewer from './EnumViewer.vue' 8 | import JsonViewer from './JsonViewer.vue' 9 | 10 | const viewers: {[key in BaseScalarName]?: any} = { 11 | str: StrViewer, 12 | bytes: BytesViewer, 13 | enum: EnumViewer, 14 | json: JsonViewer, 15 | } 16 | 17 | export default Vue.extend({ 18 | functional: true, 19 | props: ['item', 'codec'], 20 | render(h, {props: {item, codec}}: {props: {item: any, codec: ICodec}}) { 21 | const renderer = codec.getKind() === 'scalar' ? 22 | viewers[(codec as unknown as ScalarCodec).getBaseScalarName()] 23 | : null 24 | 25 | if (!renderer) { 26 | return h( 27 | 'div', 28 | {staticClass: 'data-viewer-no-viewer'}, 29 | 'No details for selected item' 30 | ) 31 | } 32 | return h( 33 | renderer, 34 | { 35 | props: {item, codec} 36 | } 37 | ) 38 | } 39 | }) 40 | -------------------------------------------------------------------------------- /src/renderer/components/dataViewers/EnumViewer.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 30 | 31 | 42 | -------------------------------------------------------------------------------- /src/renderer/components/dataViewers/JsonViewer.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 47 | 48 | 66 | -------------------------------------------------------------------------------- /src/renderer/components/dataViewers/StrViewer.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 39 | 40 | 74 | -------------------------------------------------------------------------------- /src/renderer/components/renderers/ItemType.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | 3 | import { typesResolverModule } from '@store/typesResolver' 4 | import { ICodec, ScalarCodec } from '@utils/edgedb' 5 | 6 | function getTypeName(item: any, codec: ICodec, limit: number): string { 7 | switch (codec.getKind()) { 8 | case 'object': 9 | return typesResolverModule.types[item.__tid__?.toRawString()]?.name || 'Object' 10 | case 'array': 11 | return `Array(${item.length})` 12 | case 'set': 13 | const limited = limit && item.length === (limit+1) 14 | return `Set(${limited ? limit+'+' : item.length})` 15 | case 'tuple': 16 | return `Tuple(${item.length})` 17 | case 'namedtuple': 18 | return `NamedTuple(${item.length})` 19 | case 'scalar': 20 | const baseScalarName = (codec as unknown as ScalarCodec).getBaseScalarName() 21 | switch (baseScalarName) { 22 | case 'bytes': 23 | return `bytes(${item.length})` 24 | default: 25 | const customName = typesResolverModule.types[codec.tid]?.name 26 | return baseScalarName + (customName?`<${customName}>`:'') 27 | } 28 | } 29 | } 30 | 31 | interface Props { 32 | item: any, 33 | codec: ICodec, 34 | implicitlimit: number 35 | } 36 | 37 | export default Vue.extend({ 38 | functional: true, 39 | props: ['item', 'codec', 'implicitlimit'], 40 | render(h, {props: {item, codec, implicitlimit}}: {props: Props}) { 41 | const typeName = getTypeName(item, codec, implicitlimit) 42 | return h('span', { 43 | staticClass: 'item-type'+( 44 | codec.getKind() === 'object' && typeName !== 'Object' ? ' item-type-object' : '' 45 | ) 46 | }, typeName) 47 | } 48 | }) 49 | -------------------------------------------------------------------------------- /src/renderer/components/renderers/ItemValue.ts: -------------------------------------------------------------------------------- 1 | import { CreateElement } from 'vue' 2 | import { Vue, Component, Prop } from 'vue-property-decorator' 3 | import ItemType from './ItemType' 4 | import { ICodec, ScalarCodec, BaseScalarName } from '@utils/edgedb' 5 | 6 | function zeroPad(n: number, padding = 2) { 7 | return n.toString().padStart(padding, '0') 8 | } 9 | 10 | function getPreview(h: CreateElement, value: any, typeName: BaseScalarName, short: boolean) { 11 | switch (typeName) { 12 | case 'bytes': { 13 | const vnodes = [...value.slice(0, 20)].map(n => h('span', n.toString(16).padStart(2, '0'))) 14 | if (value.length > 20) { 15 | vnodes.push( h('span', {staticClass: 'whitespace-char'}, '…') ) 16 | } 17 | return vnodes 18 | } 19 | case 'str': { 20 | const maxLen = short ? 20 : 100 21 | const vnodes = value.slice(0, maxLen).split(/\r|\n|\r\n/) 22 | .flatMap(str => { 23 | return [str, h('span', {staticClass: 'whitespace-char'}, '↵')] 24 | }).slice(0, -1) 25 | if (value.length > maxLen) { 26 | vnodes.push( h('span', {staticClass: 'whitespace-char'}, '…') ) 27 | } 28 | return vnodes 29 | } 30 | case 'json': { 31 | const maxLen = short ? 20 : 100 32 | const vnodes = [value.slice(0, maxLen)] 33 | if (value.length > maxLen) { 34 | vnodes.push( h('span', {staticClass: 'whitespace-char'}, '…') ) 35 | } 36 | return vnodes 37 | } 38 | case 'datetime': { 39 | const [isoDate, isoTime] = value.toISOString().split('T') 40 | return `${isoDate} ${isoTime.slice(0, -1)} UTC` 41 | } 42 | case 'localdatetime': { 43 | const [isoDate, isoTime] = value.toISOString().split('T') 44 | return `${isoDate} ${isoTime}` 45 | } 46 | case 'float32': 47 | return value.toPrecision(8) 48 | default: 49 | return value.toString() 50 | } 51 | } 52 | 53 | 54 | @Component 55 | export default class ItemValue extends Vue { 56 | @Prop() 57 | item: any 58 | 59 | @Prop() 60 | codec: ICodec 61 | 62 | @Prop({ 63 | default: false, 64 | }) 65 | short: boolean 66 | 67 | @Prop() 68 | implicitlimit: number 69 | 70 | 71 | render(h: CreateElement) { 72 | if (this.item === null) { 73 | return h('span', {staticClass: 'value-type-null'}) 74 | } 75 | 76 | const codecKind = this.codec.getKind(), 77 | baseScalarName = (this.codec as unknown as ScalarCodec).getBaseScalarName?.() 78 | 79 | if (codecKind !== 'scalar' || (this.short && baseScalarName === 'bytes')) { 80 | return h(ItemType, { 81 | props: this.$props 82 | }) 83 | } 84 | 85 | return h('span', { 86 | attrs: this.$attrs, 87 | staticClass: 'value-type-'+baseScalarName, 88 | }, getPreview(h, this.item, baseScalarName, this.short)) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/renderer/components/schemaView/DetailsItem.vue: -------------------------------------------------------------------------------- 1 | 55 | 56 | 107 | 108 | 156 | -------------------------------------------------------------------------------- /src/renderer/components/schemaView/SchemaDetails.vue: -------------------------------------------------------------------------------- 1 | 47 | 48 | 71 | 72 | 119 | -------------------------------------------------------------------------------- /src/renderer/components/schemaView/SchemaLink.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 127 | 128 | 156 | -------------------------------------------------------------------------------- /src/renderer/components/schemaView/SchemaTable.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 65 | 66 | 97 | -------------------------------------------------------------------------------- /src/renderer/components/schemaView/SchemaTableItem.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 97 | 98 | 151 | -------------------------------------------------------------------------------- /src/renderer/components/settings/ConnectionPage.vue: -------------------------------------------------------------------------------- 1 | 81 | 82 | 192 | 193 | 229 | -------------------------------------------------------------------------------- /src/renderer/components/settings/DumpPage.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 112 | 113 | 155 | -------------------------------------------------------------------------------- /src/renderer/components/settings/RestorePage.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 131 | 132 | 188 | -------------------------------------------------------------------------------- /src/renderer/components/tabViews/ConnSettingsView.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 71 | 72 | 102 | -------------------------------------------------------------------------------- /src/renderer/components/tabViews/SchemaView.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 66 | 67 | 92 | -------------------------------------------------------------------------------- /src/renderer/index.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import PortalVue from 'portal-vue' 3 | import Vuelidate from 'vuelidate' 4 | 5 | import App from './App.vue' 6 | import store from './store/store' 7 | 8 | Vue.config.productionTip = false 9 | 10 | Vue.use(PortalVue) 11 | Vue.use(Vuelidate) 12 | 13 | new Vue({ 14 | el: '#app', 15 | store, 16 | render: h => h(App), 17 | }) 18 | -------------------------------------------------------------------------------- /src/renderer/renderer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | EdgeDB UI 7 | 8 | 9 | 10 |
11 | $bundles 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/renderer/schemaLayout/runLayout.ts: -------------------------------------------------------------------------------- 1 | import nanoid from 'nanoid' 2 | import { SchemaObject } from '@store/tabs/schema' 3 | 4 | const worker = new Worker('schemaLayoutWorker.js') 5 | 6 | type SchemaGraphItem = SchemaObject & { 7 | size: { 8 | width: number 9 | height: number 10 | } 11 | } 12 | 13 | interface Point { 14 | x: number 15 | y: number 16 | } 17 | 18 | interface LayoutNode { 19 | cx: number 20 | cy: number 21 | width: number 22 | height: number 23 | } 24 | 25 | export interface GraphLayout { 26 | width: number 27 | height: number 28 | nodes: LayoutNode[] 29 | routes: { 30 | path: Point[] 31 | midPoint: Point 32 | midPointDirection: 'N' | 'E' | 'S' | 'W' 33 | link: { 34 | index: number, 35 | type: 'inherits' | 'relation', 36 | source: LayoutNode, 37 | target: LayoutNode 38 | } 39 | }[] 40 | } 41 | 42 | const waitingLayouts = new Map any 44 | reject: () => any 45 | }>() 46 | 47 | export async function layoutSchema( 48 | schema: SchemaGraphItem[], 49 | gridSize: number 50 | ): Promise { 51 | const layoutId = nanoid() 52 | return new Promise((resolve, reject) => { 53 | waitingLayouts.set(layoutId, { 54 | resolve, reject 55 | }) 56 | 57 | worker.postMessage({ 58 | layoutId, 59 | schema, 60 | gridSize, 61 | }) 62 | }) 63 | } 64 | 65 | worker.onmessage = function({data}) { 66 | const waitingLayout = waitingLayouts.get(data.layoutId) 67 | 68 | if (waitingLayout) { 69 | waitingLayout.resolve(data.layout) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/renderer/store/resizablePanel.ts: -------------------------------------------------------------------------------- 1 | import { Module, VuexModule, Action, Mutation } from 'vuex-class-modules' 2 | 3 | @Module 4 | export class ResizablePanelModule extends VuexModule { 5 | layout: 'vertical' | 'horizontal' = 'vertical' 6 | 7 | isCollapsed = true 8 | 9 | size: number = 50 10 | 11 | @Mutation 12 | setCollapsed(collapsed?: boolean) { 13 | this.isCollapsed = collapsed ?? !this.isCollapsed 14 | } 15 | 16 | @Mutation 17 | updateSize(size: number) { 18 | this.size = size > 90 ? 90 : (size < 10 ? 10 : size) 19 | } 20 | 21 | @Mutation 22 | changeLayout(layout?: 'vertical' | 'horizontal') { 23 | if (!layout) { 24 | this.layout = this.layout === 'vertical' ? 'horizontal' : 'vertical' 25 | } else { 26 | this.layout = layout 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/renderer/store/store.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex from 'vuex' 3 | 4 | Vue.use(Vuex) 5 | 6 | const store = new Vuex.Store({}) 7 | 8 | export default store 9 | -------------------------------------------------------------------------------- /src/renderer/store/tabs.ts: -------------------------------------------------------------------------------- 1 | import { Module, VuexModule, Action, Mutation } from 'vuex-class-modules' 2 | import nanoid from 'nanoid' 3 | 4 | import store from './store' 5 | import { QueryTabModule } from './tabs/query' 6 | import { SchemaTabModule } from './tabs/schema' 7 | import { DataTabModule } from './tabs/data' 8 | import { ConnSettingsTabModule } from './tabs/connSettings' 9 | 10 | const TabTypes = { 11 | 'query': QueryTabModule, 12 | 'schema': SchemaTabModule, 13 | 'data': DataTabModule, 14 | 'connSettings': ConnSettingsTabModule, 15 | } 16 | 17 | type Tab = { 18 | id: string 19 | type: T 20 | module: (typeof TabTypes)[T] 21 | } 22 | 23 | export interface ITabModule { 24 | title: string 25 | init?: ({id, config, module}: {id: string, config: any, module: T}) => Promise 26 | destroy?: ({id, module}: {id: string, module: T}) => void 27 | } 28 | 29 | @Module 30 | class TabsModule extends VuexModule { 31 | currentTabId: string = null 32 | 33 | tabs: Tab[] = [] 34 | 35 | get currentTab() { 36 | return this.tabs.find(tab => tab.id === this.currentTabId) 37 | } 38 | 39 | @Action 40 | private _createTab( 41 | {type, config}: {type: T, config?: any} 42 | ) { 43 | const id = `tab-${type}-${nanoid()}`, 44 | // @ts-ignore 45 | newTab: Tab = { 46 | id, 47 | type, 48 | // @ts-ignore 49 | module: new TabTypes[type]({store, name: id}) 50 | } 51 | // @ts-ignore 52 | newTab.module.init?.({id, config, module: newTab.module}) 53 | this.addTab(newTab) 54 | } 55 | 56 | @Action 57 | newQueryTab() { 58 | this._createTab({ 59 | type: 'query' 60 | }) 61 | } 62 | 63 | @Action 64 | newSchemaTab({connectionId, database}: {connectionId: string, database: string}) { 65 | this._createTab({ 66 | type: 'schema', 67 | config: {connectionId, database}, 68 | }) 69 | } 70 | 71 | @Action 72 | newDataTab({connectionId, database, objectName}: {connectionId: string, database: string, objectName: string}) { 73 | this._createTab({ 74 | type: 'data', 75 | config: {connectionId, database, objectName} 76 | }) 77 | } 78 | 79 | @Action 80 | newConnSettingsTab({connectionId}: {connectionId?: string} = {}) { 81 | this._createTab({ 82 | type: 'connSettings', 83 | config: {connectionId} 84 | }) 85 | } 86 | 87 | @Action 88 | openConnSettingsTab({connectionId}: {connectionId: string}) { 89 | const tab = this.tabs.find(tab => tab.type === 'connSettings' && 90 | (tab.module as unknown as ConnSettingsTabModule).connectionId === connectionId) 91 | 92 | if (tab) this.focusTab(tab.id) 93 | else this.newConnSettingsTab({connectionId}) 94 | } 95 | 96 | @Mutation 97 | addTab(tab: Tab) { 98 | this.tabs.push(tab) 99 | this.currentTabId = tab.id 100 | } 101 | 102 | @Mutation 103 | removeTab(id: string) { 104 | const removeIndex = this.tabs.findIndex(tab => tab.id === id) 105 | const tab = this.tabs.splice(removeIndex, 1)[0] 106 | 107 | ;(tab.module as unknown as ITabModule).destroy?.({ 108 | id, module: tab.module as any 109 | }) 110 | store.unregisterModule(id) 111 | if (id === this.currentTabId) { 112 | this.currentTabId = this.tabs[removeIndex]?.id || this.tabs[removeIndex-1]?.id || null 113 | } 114 | } 115 | 116 | @Mutation 117 | focusTab(id: string) { 118 | this.currentTabId = id 119 | } 120 | } 121 | 122 | export const tabsModule = new TabsModule({store, name: 'tabs'}) 123 | -------------------------------------------------------------------------------- /src/renderer/store/tabs/connSettings.ts: -------------------------------------------------------------------------------- 1 | import { Module, VuexModule, Mutation, Action } from 'vuex-class-modules' 2 | import nanoid from 'nanoid' 3 | 4 | import { ITabModule } from '@store/tabs' 5 | import { connectionsModule } from '@store/connections' 6 | import { ConnectionConfig, DumpRestoreRequest, DumpFileInfo } from '@shared/interfaces' 7 | 8 | const ipc = (window as any).ipc 9 | 10 | @Module 11 | export class ConnSettingsTabModule extends VuexModule implements ITabModule { 12 | connectionId = '' 13 | config = { 14 | name: '', 15 | host: '', 16 | port: null, 17 | user: '', 18 | password: '', 19 | defaultDatabase: '', 20 | useSSH: false, 21 | ssh_host: '', 22 | ssh_port: null, 23 | ssh_user: '', 24 | ssh_authType: 'password', 25 | ssh_password: '', 26 | ssh_keyFile: '', 27 | ssh_passphrase: '', 28 | } 29 | configModified = false 30 | 31 | get title() { 32 | return `${this.connectionName || 'New Connection'} Settings` 33 | } 34 | 35 | get connectionName() { 36 | return connectionsModule.connections[this.connectionId]?.name || '' 37 | } 38 | 39 | get connectionDatabaseNames() { 40 | const dbs = connectionsModule.connections[this.connectionId]?.databases 41 | return dbs && Object.keys(dbs) 42 | } 43 | 44 | @Action 45 | async init( 46 | {config}: {config: {connectionId?: string}} 47 | ) { 48 | this.setConnectionId(config.connectionId || nanoid()) 49 | this.getConnectionConfig() 50 | } 51 | 52 | @Mutation 53 | private setConnectionId(connectionId: string) { 54 | this.connectionId = connectionId 55 | } 56 | 57 | @Action 58 | async getConnectionConfig() { 59 | const config: ConnectionConfig = await ipc.callMain('connections:getConnectionConfig', this.connectionId) 60 | if (config) this.setConfig(config) 61 | } 62 | 63 | @Action 64 | async saveConnectionConfig() { 65 | const config: ConnectionConfig = { 66 | id: this.connectionId, 67 | name: this.config.name, 68 | host: this.config.host, 69 | port: this.config.port || undefined, 70 | user: this.config.user, 71 | password: this.config.password, 72 | defaultDatabase: this.config.defaultDatabase || undefined, 73 | ssh: this.config.useSSH ? { 74 | host: this.config.ssh_host, 75 | port: this.config.ssh_port || undefined, 76 | user: this.config.ssh_user, 77 | auth: this.config.ssh_authType === 'password' ? { 78 | type: 'password', 79 | password: this.config.ssh_password, 80 | } : { 81 | type: 'key', 82 | keyFile: this.config.ssh_keyFile, 83 | passphrase: this.config.ssh_passphrase || undefined 84 | } 85 | } : undefined 86 | } 87 | const result = await ipc.callMain('connections:testAndSaveConfig', config) 88 | this.setConfigModified(false) 89 | 90 | return result 91 | } 92 | 93 | @Mutation 94 | updateConfig({key, val}) { 95 | this.config[key] = val 96 | this.configModified = true 97 | } 98 | 99 | @Mutation 100 | private setConfigModified(modified: boolean) { 101 | this.configModified = modified 102 | } 103 | 104 | @Mutation 105 | setConfig({name, host, port, user, password, defaultDatabase, ssh}: ConnectionConfig) { 106 | this.config = { 107 | name, 108 | host, 109 | port: port || null, 110 | user, 111 | password, 112 | defaultDatabase: defaultDatabase || '', 113 | useSSH: !!ssh, 114 | ssh_host: ssh?.host, 115 | ssh_port: ssh?.port || null, 116 | ssh_user: ssh?.user, 117 | ssh_authType: ssh?.auth.type || 'password', 118 | ssh_password: (ssh?.auth as any)?.password || '', 119 | ssh_keyFile: (ssh?.auth as any)?.keyFile || '', 120 | ssh_passphrase: (ssh?.auth as any)?.passphrase || '', 121 | } 122 | } 123 | 124 | @Action 125 | async dumpDatabase( 126 | {database, path, progress}: 127 | {database: string, path: string, progress: (bytes: number) => void} 128 | ) { 129 | const stopListening = ipc.listenBroadcast('dump:bytesWritten', 130 | ({path: progressPath, bytesWritten}) => { 131 | if (progressPath === path) progress(bytesWritten) 132 | } 133 | ) 134 | 135 | try { 136 | await ipc.callMain('dump:begin', { 137 | connectionId: this.connectionId, 138 | database, 139 | path 140 | } as DumpRestoreRequest) 141 | } finally { 142 | stopListening() 143 | } 144 | } 145 | 146 | @Action 147 | async checkDumpFile(path: string): Promise { 148 | return await ipc.callMain('restore:checkFile', path) 149 | } 150 | 151 | @Action 152 | async restoreDatabase( 153 | {database, path, progress}: 154 | {database: string, path: string, progress: (bytes: number) => void} 155 | ) { 156 | const stopListening = ipc.listenBroadcast('restore:bytesRead', 157 | ({path: progressPath, bytesRead}) => { 158 | if (progressPath === path) progress(bytesRead) 159 | } 160 | ) 161 | 162 | try { 163 | await ipc.callMain('restore:begin', { 164 | connectionId: this.connectionId, 165 | database, 166 | path 167 | } as DumpRestoreRequest) 168 | } finally { 169 | stopListening() 170 | } 171 | 172 | if (connectionsModule.connections[this.connectionId]?.databases) { 173 | await connectionsModule.fetchDatabases({connectionId: this.connectionId}) 174 | if (connectionsModule.connections[this.connectionId].databases[database]?.modules) { 175 | connectionsModule.fetchModules({connectionId: this.connectionId, database}) 176 | } 177 | } 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/renderer/store/tabs/data.ts: -------------------------------------------------------------------------------- 1 | import { Module, VuexModule, Mutation, Action } from 'vuex-class-modules' 2 | 3 | import store from '@store/store' 4 | import { deepSeal, formatName } from '@utils/misc' 5 | import { connectionsModule } from '@store/connections' 6 | import { ITabModule } from '@store/tabs' 7 | import { ICodec, decodeRawResult, EdgeDBSet } from '@utils/edgedb' 8 | import { ResizablePanelModule } from '@store/resizablePanel' 9 | 10 | type Column = { 11 | kind: 'property' | 'link' 12 | index: number 13 | name: string 14 | required: boolean 15 | many: boolean 16 | typeName: string 17 | } 18 | 19 | interface CellPos { 20 | row: number 21 | col: string 22 | } 23 | 24 | @Module 25 | export class DataTabModule extends VuexModule implements ITabModule { 26 | connectionId = '' 27 | database = '' 28 | objectName = '' 29 | detailPanel: ResizablePanelModule = null 30 | private _destroyQueryWatcher = null 31 | 32 | columns: Column[] | null = null 33 | columnCodecs: ICodec[] = null 34 | data: {[key: string]: any}[] | null = null 35 | selectedCell: CellPos = null 36 | objectsCount: number | null = null 37 | loading = false 38 | 39 | limit = 100 40 | linkLimit = 20 41 | offset = 0 42 | dataOffset = 0 43 | orderBy: { 44 | col: string 45 | dir: 'ASC' | 'DESC' 46 | }[] = [] 47 | 48 | get title() { 49 | return `${this.database} ❭ ${formatName(this.objectName)}` 50 | } 51 | 52 | get selectedItem() { 53 | if (!this.selectedCell) return; 54 | 55 | const {col, row} = this.selectedCell 56 | 57 | const item = this.data[row-1]?.[col] 58 | const codec = this.columnCodecs[this.columns.findIndex(c => c.name === col)] 59 | 60 | return codec ? { item, codec } : null 61 | } 62 | 63 | @Action 64 | async init( 65 | {id, config, module}: {id: string, config: {connectionId: string, database: string, objectName: string}, module: DataTabModule} 66 | ) { 67 | this._setupPanel( 68 | new ResizablePanelModule({store, name: id+'-detailpanel'}), 69 | ) 70 | this.setDatabaseObject(config) 71 | 72 | this._setupQueryWatcher( 73 | module.$watch( 74 | () => [this.dataQuery, this.offset, this.limit].join(','), 75 | query => { 76 | if (query) this.fetchData() 77 | } 78 | ) 79 | ) 80 | 81 | this.setLoading(true) 82 | await this.fetchColumns() 83 | } 84 | 85 | @Action 86 | async destroy({id}: {id: string}) { 87 | store.unregisterModule(id+'-detailpanel') 88 | this._destroyQueryWatcher() 89 | } 90 | 91 | get dataQuery() { 92 | if (!this.columns) return null 93 | return `SELECT ${this.objectName} { 94 | ${ 95 | this.columns.map(column => { 96 | if (column.kind === 'link') { 97 | //return `${column.name} LIMIT ${this.linkLimit}, 98 | return `${column.name}__count__ := count(.${column.name})` 99 | } 100 | return column.name 101 | }) 102 | .join(',\n') 103 | } 104 | } 105 | ${ 106 | this.orderBy.length ? ('ORDER BY ' + this.orderBy 107 | .map(o => `.${o.col} ${o.dir}`) 108 | .join(' THEN ')) : '' 109 | } 110 | OFFSET $offset 111 | LIMIT $limit` 112 | } 113 | 114 | @Action 115 | async fetchColumns() { 116 | const result = (await connectionsModule.runQuery({ 117 | connectionId: this.connectionId, 118 | database: this.database, 119 | query: `WITH MODULE schema 120 | SELECT ObjectType { 121 | links: { 122 | name, 123 | cardinality, 124 | required, 125 | targetName := .target.name, 126 | } FILTER .name != '__type__', 127 | properties: { 128 | name, 129 | cardinality, 130 | required, 131 | targetName := .target.name, 132 | }, 133 | } FILTER .name = $objectName`, 134 | args: { 135 | objectName: this.objectName 136 | } 137 | })).result?.[0] 138 | 139 | if (!result) { 140 | throw new Error('Failed to retrieve columns data') 141 | } 142 | 143 | this.updateColumns([ 144 | ...result.properties.map((prop, i) => ({ 145 | kind: 'property', 146 | index: i, 147 | name: prop.name, 148 | required: prop.required, 149 | many: prop.cardinality === 'MANY', 150 | typeName: prop.targetName 151 | } as Column)), 152 | ...result.links.map((link, i) => ({ 153 | kind: 'link', 154 | index: i + result.properties.length, 155 | name: link.name, 156 | required: link.required, 157 | many: link.cardinality === 'MANY', 158 | typeName: link.targetName, 159 | } as Column)), 160 | ]) 161 | } 162 | 163 | @Action 164 | async fetchData() { 165 | this.setLoading(true) 166 | try { 167 | const { connectionId, database, dataQuery, offset, limit } = this 168 | 169 | const {result} = await connectionsModule.runRawQuery({ 170 | connectionId, 171 | database, 172 | query: dataQuery, 173 | args: { 174 | offset, 175 | limit 176 | } 177 | }) 178 | 179 | const decodedResult = await decodeRawResult(result.result, result.outTypeId) 180 | 181 | this.updateData({...decodedResult, offset}) 182 | } finally { 183 | this.setLoading(false) 184 | } 185 | 186 | const {result: count} = await connectionsModule.runQuery({ 187 | connectionId: this.connectionId, 188 | database: this.database, 189 | query: `SELECT count(${this.objectName})` 190 | }) 191 | 192 | this.updateObjectCount(count[0]) 193 | } 194 | 195 | @Mutation 196 | setSelectedCell(cell: CellPos) { 197 | this.selectedCell = cell 198 | } 199 | 200 | @Mutation 201 | setDatabaseObject({connectionId, database, objectName}: {connectionId: string, database: string, objectName: string}) { 202 | this.connectionId = connectionId 203 | this.database = database 204 | this.objectName = objectName 205 | } 206 | 207 | @Mutation 208 | setLoading(loading: boolean) { 209 | this.loading = loading 210 | } 211 | 212 | @Mutation 213 | updateColumns(columns: Column[]) { 214 | this.columns = deepSeal(columns) 215 | } 216 | 217 | @Mutation 218 | updateData({result, codec, offset}: {result: EdgeDBSet, codec: ICodec, offset: number}) { 219 | this.columnCodecs = codec.getSubcodecs() 220 | this.data = deepSeal(result) 221 | this.dataOffset = offset 222 | 223 | if (this.selectedCell && this.data.length < this.selectedCell.row) { 224 | this.selectedCell = this.data.length === 0 ? null : { 225 | row: this.data.length, 226 | col: this.selectedCell.col 227 | } 228 | } 229 | } 230 | 231 | @Mutation 232 | updateObjectCount(count: number) { 233 | this.objectsCount = count 234 | } 235 | 236 | @Mutation 237 | updateOffset(offset: number) { 238 | this.offset = offset 239 | } 240 | 241 | @Mutation 242 | updateOrderBy(order: {col: string, dir: 'ASC' | 'DESC'}[]) { 243 | this.orderBy = order 244 | } 245 | 246 | @Mutation 247 | private _setupPanel(detailPanel: ResizablePanelModule) { 248 | this.detailPanel = detailPanel 249 | detailPanel.changeLayout('vertical') 250 | detailPanel.updateSize(30) 251 | } 252 | 253 | @Mutation 254 | private _setupQueryWatcher(unwatch: any) { 255 | this._destroyQueryWatcher = unwatch 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /src/renderer/store/typesResolver.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import { Module, VuexModule, Mutation, Action } from 'vuex-class-modules' 3 | 4 | import store from './store' 5 | 6 | import { ResolveTypesRequest, ResolveTypesResponse } from '@shared/interfaces' 7 | 8 | const ipc = (window as any).ipc 9 | 10 | @Module 11 | class TypesResolverModule extends VuexModule { 12 | types: { 13 | [id: string]: { 14 | name: string 15 | } 16 | } = {} 17 | 18 | @Action 19 | resolveTids(request: ResolveTypesRequest) { 20 | ipc.callMain('typesResolver:resolve', request) 21 | } 22 | 23 | @Mutation 24 | addTypes(types: ResolveTypesResponse['types']) { 25 | types.forEach(({id, name}) => { 26 | if (!name) return; 27 | 28 | const [module, typeName] = name.split('::') 29 | Vue.set(this.types, id, { 30 | name: module === 'default' ? typeName : name, 31 | }) 32 | }) 33 | } 34 | } 35 | 36 | export const typesResolverModule = new TypesResolverModule({store, name: 'typesResolver'}) 37 | 38 | ;(window as any).ipc.answerMain('typesResolver:resolved', ({types}: ResolveTypesResponse) => { 39 | typesResolverModule.addTypes(types) 40 | }) 41 | -------------------------------------------------------------------------------- /src/renderer/store/viewerSettings.ts: -------------------------------------------------------------------------------- 1 | import { Module, VuexModule, Mutation, Action } from 'vuex-class-modules' 2 | 3 | import store from './store' 4 | 5 | @Module({ 6 | generateMutationSetters: true 7 | }) 8 | class ViewerSettingsModule extends VuexModule { 9 | // string viewer 10 | wrapLines = false 11 | 12 | } 13 | 14 | export const viewerSettingsModule = new ViewerSettingsModule({store, name: 'viewerSettings'}) 15 | -------------------------------------------------------------------------------- /src/renderer/store/window.ts: -------------------------------------------------------------------------------- 1 | import { Module, VuexModule, Action, Mutation } from 'vuex-class-modules' 2 | 3 | import store from './store' 4 | 5 | @Module 6 | class WindowModule extends VuexModule { 7 | isMaximised = false 8 | 9 | accentColour = 'transparent' 10 | 11 | @Mutation 12 | setMaximised(maxmised: boolean) { 13 | this.isMaximised = maxmised 14 | } 15 | 16 | @Mutation 17 | setAccentColour(colour: string) { 18 | const r = parseInt(colour.slice(0,2), 16), 19 | g = parseInt(colour.slice(2,4), 16), 20 | b = parseInt(colour.slice(4,6), 16), 21 | a = parseInt(colour.slice(6,8), 16) / 255 22 | this.accentColour = `rgba(${r},${g},${b},${a})` 23 | } 24 | } 25 | 26 | export const windowModule = new WindowModule({store, name: 'window'}) 27 | 28 | ;(window as any).ipc.answerMain('windowControl:maximiseChanged', windowModule.setMaximised) 29 | ;(window as any).ipc.listenBroadcast('sysPref:accentColourChanged', windowModule.setAccentColour) 30 | 31 | ;(async () => { 32 | windowModule.setAccentColour( 33 | await (window as any).ipc.callMain('sysPref:getAccentColour') 34 | ) 35 | windowModule.setMaximised( 36 | await (window as any).ipc.callMain('windowControl:isMaximised') 37 | ) 38 | })() 39 | -------------------------------------------------------------------------------- /src/renderer/ui/Button.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 29 | 30 | 34 | -------------------------------------------------------------------------------- /src/renderer/ui/FilePicker.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 49 | 50 | 66 | -------------------------------------------------------------------------------- /src/renderer/ui/Icon.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 16 | 17 | 25 | -------------------------------------------------------------------------------- /src/renderer/ui/Loading.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 13 | 14 | 33 | -------------------------------------------------------------------------------- /src/renderer/ui/Progress.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 27 | 28 | 91 | -------------------------------------------------------------------------------- /src/renderer/ui/ResizablePanel.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 59 | 60 | 105 | -------------------------------------------------------------------------------- /src/renderer/ui/Select.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 26 | 27 | 66 | -------------------------------------------------------------------------------- /src/renderer/utils/edgedb.ts: -------------------------------------------------------------------------------- 1 | import { Set as EdgeDBSet } from 'edgedb/datatypes/set' 2 | import { CodecsRegistry } from 'edgedb/codecs/registry' 3 | import { ICodec, CodecKind, BaseScalarName, ScalarCodec } from 'edgedb/codecs/ifaces' 4 | import { ReadBuffer } from 'edgedb/buffer' 5 | import { ObjectCodec } from 'edgedb/codecs/object' 6 | import { UUIDCodec } from 'edgedb/codecs/uuid' 7 | import { deepSeal } from './misc' 8 | 9 | enum TransactionStatus { 10 | TRANS_IDLE = 0, // connection idle 11 | TRANS_ACTIVE = 1, // command in progress 12 | TRANS_INTRANS = 2, // idle, within transaction block 13 | TRANS_INERROR = 3, // idle, within failed transaction 14 | TRANS_UNKNOWN = 4, // cannot determine status 15 | } 16 | 17 | export { 18 | EdgeDBSet, 19 | CodecKind, 20 | ObjectCodec, 21 | ICodec, 22 | BaseScalarName, 23 | ScalarCodec, 24 | UUIDCodec, 25 | TransactionStatus, 26 | } 27 | 28 | const ipc = (window as any).ipc 29 | 30 | const codecRegistry = new CodecsRegistry() 31 | 32 | export async function decodeRawResult(resultBuf: Uint8Array, typeId: string) { 33 | const result = new EdgeDBSet(), 34 | buf = new ReadBuffer(typedArrayToBuffer(resultBuf)) 35 | 36 | let codec = codecRegistry.getCodec(typeId) 37 | if (!codec) { 38 | const typeData: Uint8Array = await ipc.callMain('connections:getTypeData', typeId) 39 | if (!typeData) throw new Error('Cannot retrieve type data') 40 | codec = codecRegistry.buildCodec(typeId, typedArrayToBuffer(typeData)) 41 | deepSeal(codec) 42 | } 43 | 44 | while (buf.length) { 45 | const blockLen = buf.readUInt32(), 46 | block = new ReadBuffer(buf.readBuffer(blockLen)) 47 | 48 | result.push(codec.decode(block)) 49 | } 50 | 51 | return {result, codec} 52 | } 53 | 54 | function typedArrayToBuffer(arr: Uint8Array) { 55 | let buf = Buffer.from(arr.buffer) 56 | if (arr.byteLength !== arr.buffer.byteLength) { 57 | buf = buf.slice(arr.byteOffset, arr.byteOffset + arr.byteLength) 58 | } 59 | return buf 60 | } 61 | 62 | export function extractTids(result: EdgeDBSet, codec: ICodec) { 63 | const tids = new Set() 64 | result.forEach(item => _extractTids(item, codec, tids)) 65 | return tids 66 | } 67 | 68 | function _extractTids(item: any, codec: ICodec, tids: Set) { 69 | tids.add(codec.tid) 70 | const subcodecs = codec.getSubcodecs() 71 | switch (codec.getKind()) { 72 | case 'object': 73 | tids.add(item.__tid__.toRawString()) 74 | codec.getSubcodecsNames().forEach((name, i) => { 75 | _extractTids(item[name], subcodecs[i], tids) 76 | }) 77 | break; 78 | case 'set': 79 | case 'array': 80 | (item as any[]).forEach((child) => { 81 | _extractTids(child, subcodecs[0], tids) 82 | }) 83 | break; 84 | case 'tuple': 85 | case 'namedtuple': 86 | (item as any[]).forEach((child, i) => { 87 | _extractTids(child, subcodecs[i], tids) 88 | }) 89 | break; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/renderer/utils/ipc.ts: -------------------------------------------------------------------------------- 1 | import { createDecorator } from 'vue-class-component' 2 | 3 | export function ipcListen(channel: string) { 4 | return createDecorator((options, key) => { 5 | let destroyListener 6 | 7 | if (!options.mixins) options.mixins = [] 8 | options.mixins.push({ 9 | created() { 10 | destroyListener = (window as any).ipc.answerMain(channel, this[key]) 11 | }, 12 | destroyed: () => destroyListener() 13 | }) 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /src/renderer/utils/jsonParser.ts: -------------------------------------------------------------------------------- 1 | // Modified from https://github.com/douglascrockford/JSON-js/blob/107fc93c94aa3a9c7b48548631593ecf3aac60d2/json_parse.js 2 | /* 3 | json_parse.js 4 | 2016-05-02 5 | 6 | Public Domain. 7 | 8 | NO WARRANTY EXPRESSED OR IMPLIED. USE AT YOUR OWN RISK. 9 | 10 | This file creates a json_parse function. 11 | 12 | json_parse(text, reviver) 13 | This method parses a JSON text to produce an object or array. 14 | It can throw a SyntaxError exception. 15 | 16 | The optional reviver parameter is a function that can filter and 17 | transform the results. It receives each of the keys and values, 18 | and its return value is used instead of the original value. 19 | If it returns what it received, then the structure is not modified. 20 | If it returns undefined then the member is deleted. 21 | 22 | Example: 23 | 24 | // Parse the text. Values that look like ISO date strings will 25 | // be converted to Date objects. 26 | 27 | myData = json_parse(text, function (key, value) { 28 | var a; 29 | if (typeof value === "string") { 30 | a = 31 | /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}(?:\.\d*)?)Z$/.exec(value); 32 | if (a) { 33 | return new Date(Date.UTC(+a[1], +a[2] - 1, +a[3], +a[4], 34 | +a[5], +a[6])); 35 | } 36 | } 37 | return value; 38 | }); 39 | 40 | This is a reference implementation. You are free to copy, modify, or 41 | redistribute. 42 | 43 | This code should be minified before deployment. 44 | See http://javascript.crockford.com/jsmin.html 45 | 46 | USE YOUR OWN COPY. IT IS EXTREMELY UNWISE TO LOAD CODE FROM SERVERS YOU DO 47 | NOT CONTROL. 48 | */ 49 | 50 | export class JSONNumber { 51 | constructor(public val: string) { 52 | if (!isFinite(Number(val))) { 53 | error("Bad number") 54 | } 55 | } 56 | } 57 | 58 | const escapee = { 59 | "\"": "\"", 60 | "\\": "\\", 61 | "/": "/", 62 | b: "\b", 63 | f: "\f", 64 | n: "\n", 65 | r: "\r", 66 | t: "\t" 67 | } 68 | 69 | let at: number 70 | let ch: string 71 | let text: string 72 | 73 | function error(m: string) { 74 | throw new SyntaxError(`${m} at position ${at}`) 75 | } 76 | 77 | function next(c?: string) { 78 | // If a c parameter is provided, verify that it matches the current character. 79 | if (c && c !== ch) { 80 | error("Expected '" + c + "' instead of '" + ch + "'"); 81 | } 82 | 83 | // Get the next character. When there are no more characters, 84 | // return the empty string. 85 | return ch = text.charAt(at++) 86 | } 87 | 88 | function number() { 89 | // Parse a number value. 90 | let string = "" 91 | 92 | if (ch === "-") { 93 | string = "-" 94 | next("-") 95 | } 96 | while (ch >= "0" && ch <= "9") { 97 | string += ch 98 | next() 99 | } 100 | if (ch === ".") { 101 | string += "." 102 | while (next() && ch >= "0" && ch <= "9") { 103 | string += ch 104 | } 105 | } 106 | if (ch === "e" || ch === "E") { 107 | string += ch 108 | next() 109 | // @ts-ignore 110 | if (ch === "-" || ch === "+") { 111 | string += ch 112 | next() 113 | } 114 | while (ch >= "0" && ch <= "9") { 115 | string += ch 116 | next() 117 | } 118 | } 119 | 120 | return new JSONNumber(string) 121 | } 122 | 123 | function string() { 124 | // Parse a string value. 125 | let value = "" 126 | 127 | // When parsing for string values, we must look for " and \ characters. 128 | if (ch === "\"") { 129 | while (next()) { 130 | if (ch === "\"") { 131 | next() 132 | return value 133 | } 134 | if (ch === "\\") { 135 | next() 136 | if (ch === "u") { 137 | let uffff = 0 138 | for (let i = 0; i < 4; i += 1) { 139 | const hex = parseInt(next(), 16) 140 | if (!isFinite(hex)) { 141 | break 142 | } 143 | uffff = uffff * 16 + hex 144 | } 145 | value += String.fromCharCode(uffff) 146 | } else if (escapee[ch] !== undefined) { 147 | value += escapee[ch] 148 | } else { 149 | break 150 | } 151 | } else { 152 | value += ch 153 | } 154 | } 155 | } 156 | error("Bad string") 157 | } 158 | 159 | function white() { 160 | // Skip whitespace. 161 | while (ch && ch <= " ") { 162 | next() 163 | } 164 | } 165 | 166 | function word() { 167 | // true, false, or null. 168 | switch (ch) { 169 | case "t": 170 | next("t") 171 | next("r") 172 | next("u") 173 | next("e") 174 | return true 175 | case "f": 176 | next("f") 177 | next("a") 178 | next("l") 179 | next("s") 180 | next("e") 181 | return false 182 | case "n": 183 | next("n") 184 | next("u") 185 | next("l") 186 | next("l") 187 | return null 188 | } 189 | error("Unexpected '" + ch + "'") 190 | } 191 | 192 | function array() { 193 | // Parse an array value. 194 | const arr = [] 195 | 196 | if (ch === "[") { 197 | next("[") 198 | white() 199 | // @ts-ignore 200 | if (ch === "]") { 201 | next("]") 202 | return arr // empty array 203 | } 204 | while (ch) { 205 | arr.push(value()) 206 | white() 207 | // @ts-ignore 208 | if (ch === "]") { 209 | next("]") 210 | return arr 211 | } 212 | next(",") 213 | white() 214 | } 215 | } 216 | error("Bad array") 217 | } 218 | 219 | function object() { 220 | // Parse an object value. 221 | // var key; 222 | const obj = {} 223 | 224 | if (ch === "{") { 225 | next("{") 226 | white() 227 | // @ts-ignore 228 | if (ch === "}") { 229 | next("}") 230 | return obj // empty object 231 | } 232 | while (ch) { 233 | const key = string() 234 | white() 235 | next(":") 236 | if (obj.hasOwnProperty(key)) { 237 | error("Duplicate key '" + key + "'") 238 | } 239 | obj[key] = value() 240 | white() 241 | // @ts-ignore 242 | if (ch === "}") { 243 | next("}") 244 | return obj 245 | } 246 | next(",") 247 | white() 248 | } 249 | } 250 | error("Bad object") 251 | } 252 | 253 | function value(): any { 254 | // Parse a JSON value. It could be an object, an array, a string, a number, 255 | // or a word. 256 | 257 | white() 258 | switch (ch) { 259 | case "{": 260 | return object() 261 | case "[": 262 | return array() 263 | case "\"": 264 | return string() 265 | case "-": 266 | return number() 267 | default: 268 | return (ch >= "0" && ch <= "9") 269 | ? number() 270 | : word() 271 | } 272 | } 273 | 274 | export function JSONParse(source: string, reviver?: (this: any, key: string, value: any) => any) { 275 | text = source 276 | at = 0 277 | ch = " " 278 | 279 | const result = value() 280 | 281 | white() 282 | if (ch) { 283 | error("Syntax error") 284 | } 285 | 286 | // If there is a reviver function, we recursively walk the new structure, 287 | // passing each name/value pair to the reviver function for possible 288 | // transformation, starting with a temporary root object that holds the result 289 | // in an empty key. If there is not a reviver function, we simply return the 290 | // result. 291 | 292 | return (typeof reviver === "function") 293 | ? (function walk(holder, key) { 294 | const val = holder[key] 295 | if (val && typeof val === "object" && !(val instanceof JSONNumber)) { 296 | for (let k in val) { 297 | if (val.hasOwnProperty(k)) { 298 | const v = walk(val, k) 299 | if (v !== undefined) { 300 | val[k] = v 301 | } else { 302 | delete val[k] 303 | } 304 | } 305 | } 306 | } 307 | return reviver.call(holder, key, val) 308 | }({"": result}, "")) 309 | : result 310 | } 311 | -------------------------------------------------------------------------------- /src/renderer/utils/misc.ts: -------------------------------------------------------------------------------- 1 | // Modified from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze 2 | const TypedArray = Object.getPrototypeOf(Uint8Array) 3 | 4 | export function deepSeal(object: T): T { 5 | const propNames = Object.getOwnPropertyNames(object); 6 | 7 | for (let name of propNames) { 8 | let value = object[name]; 9 | 10 | if(value && typeof value === "object" && !(value instanceof TypedArray)) { 11 | deepSeal(value); 12 | } 13 | } 14 | 15 | return Object.seal(object); 16 | } 17 | 18 | export function wraparoundIndex(num: number, min: number, max: number) { 19 | return num < min ? max : (num > max ? min : num); 20 | } 21 | 22 | export function formatBytes(bytes, decimals = 2) { 23 | if (bytes === 0) return '0 Bytes'; 24 | 25 | const k = 1024; 26 | const dm = decimals < 0 ? 0 : decimals; 27 | const sizes = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; 28 | 29 | const i = Math.floor(Math.log(bytes) / Math.log(k)); 30 | 31 | return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; 32 | } 33 | 34 | export function formatName(name: string, hiddenModuleNames = ['default']) { 35 | const [module, objectName] = name.split('::') 36 | return hiddenModuleNames.includes(module) ? objectName : name 37 | } 38 | -------------------------------------------------------------------------------- /src/renderer/utils/query.ts: -------------------------------------------------------------------------------- 1 | export function splitQuery(query: string): string[] { 2 | const queries: string[] = [] 3 | 4 | let bracketCount = 0, 5 | startIndex = 0 6 | 7 | for (let i = 0, len = query.length; i < len; i++) { 8 | switch (query[i]) { 9 | case '#': 10 | const lineEnd = query.indexOf('\n', i) 11 | i = lineEnd === -1 ? len : lineEnd 12 | break; 13 | case '\'': 14 | case '"': 15 | const quote = query[i] 16 | while (true) { 17 | const quoteEnd = query.indexOf(quote, i+1) 18 | i = quoteEnd === -1 ? len : quoteEnd 19 | if (quoteEnd === -1 || quoteEnd[i-1] !== '\\') break; 20 | } 21 | break; 22 | case '$': 23 | const marker = query.slice(i, query.indexOf('$', i+1)+1) 24 | const stringEnd = query.indexOf(marker, i+1) 25 | i = stringEnd === -1 ? len : stringEnd+marker.length 26 | break; 27 | case '{': 28 | bracketCount += 1 29 | break; 30 | case '}': 31 | bracketCount -= 1 32 | break; 33 | case ';': 34 | if (bracketCount === 0) { 35 | queries.push(query.slice(startIndex, i+1)) 36 | startIndex = i+1 37 | } 38 | break; 39 | } 40 | } 41 | 42 | const finalQuery = query.slice(startIndex) 43 | 44 | if (finalQuery.trim()) { 45 | queries.push(finalQuery) 46 | } 47 | 48 | return queries 49 | } 50 | -------------------------------------------------------------------------------- /src/shared/interfaces.ts: -------------------------------------------------------------------------------- 1 | import * as z from 'zod' 2 | 3 | 4 | export interface Query { 5 | connectionId: string 6 | database?: string 7 | query: string 8 | args?: {[key: string]: any} 9 | limit?: number 10 | } 11 | 12 | export interface QueryResponse { 13 | time: number 14 | result: any 15 | status: string 16 | } 17 | 18 | export interface RawQueryResponse extends QueryResponse { 19 | result: { 20 | result: Buffer 21 | inTypeId: string 22 | outTypeId: string 23 | } 24 | } 25 | 26 | export interface ResolveTypesRequest { 27 | connectionId: string 28 | database: string 29 | tids: string[] 30 | } 31 | 32 | export interface TidInfo { 33 | id: string 34 | name: string | null 35 | } 36 | 37 | export interface ResolveTypesResponse { 38 | types: TidInfo[] 39 | } 40 | 41 | export interface DumpRestoreRequest { 42 | connectionId: string 43 | database: string 44 | path: string 45 | } 46 | 47 | export interface DumpFileInfo { 48 | dumpVersion: number 49 | headerInfo: { 50 | dumpTime?: Date 51 | serverVersion?: string 52 | } 53 | fileSize: number 54 | } 55 | 56 | export const ConnectionParser = z.object({ 57 | id: z.string(), 58 | name: z.string(), 59 | host: z.string(), 60 | port: z.number().optional(), 61 | user: z.string(), 62 | password: z.string(), 63 | defaultDatabase: z.string().optional(), 64 | ssh: z.object({ 65 | host: z.string(), 66 | port: z.number().optional(), 67 | user: z.string(), 68 | auth: z.union([ 69 | z.object({ 70 | type: z.literal('password'), 71 | password: z.string() 72 | }), 73 | z.object({ 74 | type: z.literal('key'), 75 | keyFile: z.string(), 76 | passphrase: z.string().optional() 77 | }) 78 | ]), 79 | }).optional() 80 | }) 81 | 82 | export type ConnectionConfig = z.infer 83 | -------------------------------------------------------------------------------- /src/svgsymbolsplugin/helper.ts: -------------------------------------------------------------------------------- 1 | let svgSymbolsRoot: SVGElement 2 | 3 | export function SVGSymbolsInsert(name: string, viewbox: string, content: string) { 4 | if (!svgSymbolsRoot) { 5 | svgSymbolsRoot = document.createElementNS('http://www.w3.org/2000/svg', 'svg') 6 | svgSymbolsRoot.style.display = 'none' 7 | 8 | document.body.prepend(svgSymbolsRoot) 9 | } 10 | if (document.getElementById(name)) return; 11 | 12 | let symbol = document.createElementNS('http://www.w3.org/2000/svg', 'symbol') 13 | symbol.id = name 14 | symbol.setAttribute('viewBox', viewbox) 15 | symbol.innerHTML = content 16 | 17 | svgSymbolsRoot.append(symbol) 18 | } 19 | -------------------------------------------------------------------------------- /src/svgsymbolsplugin/plugin.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | class SVGSymbolsPlugin { 4 | constructor() { 5 | this.test = /\.svg$/; 6 | } 7 | init(context) { 8 | context.allowExtension(".svg"); 9 | } 10 | transform(file) { 11 | file.loadContents(); 12 | let content = file.contents, 13 | filename = file.relativePath.split(/\/|\\/).pop().split('.')[0], 14 | viewbox = (content.match(/viewBox="(.+?)"/)||[])[1], 15 | symbolContent = ((content.match(/([\s\S]*)<\/svg>/)||[])[1] || '').trim() 16 | 17 | file.contents = `window.SVGSymbolsInsert(${JSON.stringify(filename)}, ${JSON.stringify(viewbox)}, ${JSON.stringify(symbolContent)}); 18 | module.exports = '#${filename}'` 19 | } 20 | } 21 | // exports.SVGSymbolsPlugin = SVGSymbolsPlugin; 22 | exports.SVGSymbolsPlugin = () => { 23 | return new SVGSymbolsPlugin(); 24 | }; 25 | -------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "CommonJS", 4 | "target": "ES2018", 5 | "lib": ["ES2019.Array", "DOM"], 6 | "jsx": "react", 7 | "baseUrl": ".", 8 | "importHelpers": true, 9 | "emitDecoratorMetadata": true, 10 | "experimentalDecorators": true, 11 | "esModuleInterop": true, 12 | "paths": { 13 | "edgedb/*": ["edgedb/dist/src/*"], 14 | "@shared/*": ["shared/*"], 15 | "@store/*": ["renderer/store/*"], 16 | "@components/*": ["renderer/components/*"], 17 | "@utils/*": ["renderer/utils/*"], 18 | }, 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/workers/schemaLayout/aStar.ts: -------------------------------------------------------------------------------- 1 | // Modified from https://github.com/andrewrk/node-astar/blob/master/index.js 2 | 3 | import FastPriorityQueue from 'fastpriorityqueue' 4 | 5 | interface SearchNode { 6 | data: Node 7 | g: number 8 | h: number 9 | f: number 10 | parent?: SearchNode 11 | } 12 | 13 | const maxNodeSearch = 10000 14 | 15 | export function aStar(params: { 16 | startNodes: Node[], 17 | isEnd: (node: Node, prevNode?: Node) => boolean, 18 | neighbors: (node: Node, prevNode?: Node) => {node: Node, cost: number}[], 19 | heuristic: (node: Node) => number, 20 | hash?: (node: Node, prevNode?: Node) => string 21 | }) { 22 | const hash = params.hash || ((node: Node) => (node as Object).toString()) 23 | let seachedNodesCount = 0 24 | 25 | const closedDataSet = new Set(), 26 | openHeap = new FastPriorityQueue>((a, b) => b.f > a.f), 27 | openDataMap = new Map>() 28 | 29 | params.startNodes.forEach(nodeData => { 30 | const node: SearchNode = { 31 | data: nodeData, 32 | g: 0, 33 | h: params.heuristic(nodeData), 34 | f: Infinity, 35 | } 36 | node.f = node.h 37 | // leave .parent undefined 38 | openHeap.add(node) 39 | openDataMap.set(hash(node.data), node) 40 | }) 41 | let bestNode = openHeap.peek() as SearchNode 42 | 43 | const start = Date.now() 44 | while (!openHeap.isEmpty()) { 45 | if (seachedNodesCount++ > maxNodeSearch) { 46 | return { 47 | status: 'timeout', 48 | cost: bestNode.g, 49 | path: reconstructPath(bestNode), 50 | } 51 | } 52 | 53 | let node = openHeap.poll() as SearchNode, 54 | nodeHash = hash(node.data, node.parent?.data) 55 | openDataMap.delete(nodeHash) 56 | if (params.isEnd(node.data, node.parent?.data)) { 57 | // done 58 | return { 59 | status: 'success', 60 | cost: node.g, 61 | path: reconstructPath(node), 62 | } 63 | } 64 | // not done yet 65 | closedDataSet.add(nodeHash) 66 | const neighbors = params.neighbors(node.data, node.parent?.data) 67 | for (let neighborData of neighbors) { 68 | let neighborHash = hash(neighborData.node, node.data) 69 | if (closedDataSet.has(neighborHash)) { 70 | // skip closed neighbors 71 | continue 72 | } 73 | const gFromThisNode = node.g + neighborData.cost 74 | let neighborNode = openDataMap.get(neighborHash) 75 | let update = false 76 | if (neighborNode === undefined) { 77 | // add neighbor to the open set 78 | neighborNode = { 79 | data: neighborData.node, 80 | g: Infinity, 81 | h: Infinity, 82 | f: Infinity, 83 | } 84 | // other properties will be set later 85 | openDataMap.set(neighborHash, neighborNode) 86 | } else { 87 | if (neighborNode.g < gFromThisNode) { 88 | // skip this one because another route is faster 89 | continue 90 | } 91 | update = true 92 | } 93 | // found a new or better route. 94 | // update this neighbor with this node as its new parent 95 | neighborNode.parent = node 96 | neighborNode.g = gFromThisNode 97 | neighborNode.h = params.heuristic(neighborData.node) 98 | neighborNode.f = gFromThisNode + neighborNode.h 99 | if (neighborNode.h < bestNode.h) bestNode = neighborNode 100 | if (update) { 101 | openHeap.removeOne(node => node === neighborNode) 102 | } 103 | openHeap.add(neighborNode) 104 | } 105 | } 106 | // all the neighbors of every accessible node have been exhausted 107 | return { 108 | status: 'noPath', 109 | cost: bestNode.g, 110 | path: reconstructPath(bestNode), 111 | }; 112 | } 113 | 114 | function reconstructPath(fromNode: SearchNode): Node[] { 115 | const path: Node[] = [] 116 | let node: SearchNode | undefined = fromNode 117 | while (node) { 118 | path.push(node.data) 119 | node = node.parent 120 | } 121 | return path.reverse() 122 | } 123 | -------------------------------------------------------------------------------- /src/workers/schemaLayout/layout.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Layout as webcolaLayout, 3 | Link as webcolaLink, 4 | Node as webcolaNode, 5 | EventType as webcolaEventType 6 | } from 'webcola' 7 | import { routeLinks } from './routeLinks' 8 | import { SchemaObject } from '@store/tabs/schema' 9 | 10 | 11 | onmessage = function({data}) { 12 | const { layoutId, schema, gridSize } = data 13 | 14 | layoutSchema(schema, gridSize).then(layout => { 15 | // @ts-ignore 16 | postMessage({ 17 | layoutId, 18 | layout 19 | }) 20 | }) 21 | } 22 | 23 | type SchemaGraphItem = SchemaObject & { 24 | size: { 25 | width: number 26 | height: number 27 | } 28 | } 29 | 30 | interface WebcolaNode { 31 | index: number 32 | width: number 33 | height: number 34 | } 35 | export interface WebcolaLink { 36 | type: 'inherits' | 'relation' 37 | id: string 38 | source: WebcolaNode 39 | target: WebcolaNode 40 | data?: SchemaObject['links'][0] 41 | } 42 | interface WebcolaConstraint { 43 | axis: 'y' | 'x' 44 | left: number 45 | right: number 46 | gap: number 47 | } 48 | 49 | function snapToGrid(gridSize: number, value: number) { 50 | return Math.floor(value/gridSize)*gridSize 51 | } 52 | 53 | export class LayoutNode { 54 | constructor( 55 | public cx: number, 56 | public cy: number, 57 | public width: number, 58 | public height: number, 59 | ) {} 60 | 61 | get x() { 62 | return this.cx - this.width/2 63 | } 64 | 65 | get y() { 66 | return this.cy - this.height/2 67 | } 68 | } 69 | 70 | async function layoutSchema( 71 | schema: SchemaGraphItem[], 72 | gridSize: number 73 | ) { 74 | const margin = gridSize * 2.5 75 | 76 | const nodes: WebcolaNode[] = schema.map((item, i) => ({ 77 | index: i, 78 | width: item.size.width + margin*2, 79 | height: item.size.height + margin*2, 80 | })) 81 | 82 | const nodesMap = nodes.reduce((map, node) => { 83 | map[schema[node.index].name] = node 84 | return map 85 | }, {} as {[key: string]: WebcolaNode | undefined}) 86 | 87 | const links: WebcolaLink[] = [] 88 | const constraints: WebcolaConstraint[] = [] 89 | 90 | nodes.forEach((node, nodeIndex) => { 91 | const schemaItem = schema[nodeIndex] 92 | schemaItem.baseNames.forEach(baseName => { 93 | const baseNode = nodesMap[baseName] 94 | if (baseNode) { 95 | links.push({ 96 | type: 'inherits', 97 | id: `${schemaItem.name}--${baseName}`, 98 | source: node, 99 | target: baseNode 100 | }) 101 | constraints.push({ 102 | axis: 'y', 103 | left: nodes.indexOf(baseNode), 104 | right: nodeIndex, 105 | gap: 100 106 | }) 107 | } 108 | }) 109 | 110 | if (schemaItem.is_abstract) return; 111 | 112 | schemaItem.links.forEach(link => { 113 | const linkNode = nodesMap[link.targetName] 114 | if (linkNode) { 115 | links.push({ 116 | type: 'relation', 117 | id: `${schemaItem.name}.${link.name}`, 118 | source: node, 119 | target: linkNode, 120 | data: link, 121 | }) 122 | } 123 | }) 124 | }) 125 | 126 | console.time('webcola layout') 127 | const layout = await runLayout(nodes, links, constraints) 128 | console.timeEnd('webcola layout') 129 | 130 | const positionedNodes = layout.nodes() 131 | .map((node, i) => { 132 | const layoutNode = new LayoutNode( 133 | Math.round(node.x), 134 | Math.round(node.y), 135 | schema[i].size.width, 136 | schema[i].size.height 137 | ) 138 | layoutNode.cx = snapToGrid(gridSize, layoutNode.x) + layoutNode.width/2 139 | layoutNode.cy = snapToGrid(gridSize, layoutNode.y) + layoutNode.height/2 140 | 141 | return layoutNode 142 | }) 143 | 144 | const xMin = Math.min(...positionedNodes.map(node => node.x - margin)), //- gridSize/2, 145 | yMin = Math.min(...positionedNodes.map(node => node.y - margin)), //- gridSize/2, 146 | width = Math.max(...positionedNodes.map(node => node.x + node.width + margin)) - xMin, 147 | height = Math.max(...positionedNodes.map(node => node.y + node.height + margin)) - yMin 148 | 149 | positionedNodes.forEach(node => { 150 | node.cx -= xMin 151 | node.cy -= yMin 152 | }) 153 | 154 | const routedLinks = routeLinks(positionedNodes, links, gridSize) 155 | 156 | return { 157 | width, 158 | height, 159 | nodes: positionedNodes, 160 | routes: routedLinks.routes, 161 | grid: routedLinks.layoutGrid, 162 | } 163 | } 164 | 165 | function runLayout(nodes: WebcolaNode[], links: WebcolaLink[], constraints: WebcolaConstraint[]) { 166 | return new Promise((resolve: (value: webcolaLayout) => void, reject) => { 167 | const layout = new webcolaLayout() 168 | 169 | layout 170 | .handleDisconnected(false) 171 | .linkDistance(nodes[0].width*0.7) 172 | .avoidOverlaps(true) 173 | .nodes(nodes) 174 | .links(links as unknown as webcolaLink[]) 175 | .constraints(constraints) 176 | // .on(webcolaEventType.start, () => console.log('start')) 177 | // .on(webcolaEventType.tick, () => console.log('tick')) 178 | .on(webcolaEventType.end, () => { 179 | // console.log('end') 180 | resolve(layout) 181 | }) 182 | 183 | layout.start(20, 20, 20, 0) 184 | }) 185 | } 186 | --------------------------------------------------------------------------------