['"])(\\['"]|\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 | 2 |9 |15 | 16 | 17 | 51 | 52 | 177 | -------------------------------------------------------------------------------- /src/renderer/components/ConnectionsPanel.vue: -------------------------------------------------------------------------------- 1 | 2 |10 | 11 | 12 | 13 | 14 | 3 |15 | 16 | 17 | 44 | 45 | 84 | -------------------------------------------------------------------------------- /src/renderer/components/MainPanel.vue: -------------------------------------------------------------------------------- 1 | 2 |4 | loading... 5 |6 |7 |11 |8 |10 |9 | 12 |14 |New Connection 13 | 3 |11 | 12 | 13 | 67 | -------------------------------------------------------------------------------- /src/renderer/components/ModuleProvider.vue: -------------------------------------------------------------------------------- 1 | 2 |4 | 10 |5 | 8 | 9 |7 | 3 |5 | 6 | 7 | 19 | -------------------------------------------------------------------------------- /src/renderer/components/QueryErrorView.vue: -------------------------------------------------------------------------------- 1 | 2 |4 | 3 |20 | 21 | 22 | 133 | 134 | 172 | -------------------------------------------------------------------------------- /src/renderer/components/ResultsTreeView.vue: -------------------------------------------------------------------------------- 1 | 2 |4 |7 | 8 |5 | {{ isEdgeDBError ? errorName : 'Application Error' }} 6 | {{ hint }}9 | 10 |Open a new issue on GitHub11 | 12 | 13 | 17 |{{ stackTrace }}18 | 19 |9 | 43 | 44 | 45 | 134 | 135 | 275 | -------------------------------------------------------------------------------- /src/renderer/components/SchemaGraph.vue: -------------------------------------------------------------------------------- 1 | 2 | 45 | 46 | 47 | 94 | 95 | 154 | -------------------------------------------------------------------------------- /src/renderer/components/StatusBar.vue: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 22 | 23 | 47 | -------------------------------------------------------------------------------- /src/renderer/components/TabBar.vue: -------------------------------------------------------------------------------- 1 | 2 | 18 | 19 | 20 | 48 | 49 | 145 | -------------------------------------------------------------------------------- /src/renderer/components/WindowControls.vue: -------------------------------------------------------------------------------- 1 | 2 |14 |36 | 42 |16 | 17 | {{ row.name }} := 18 | 19 | 21 | 22 |25 |24 | 26 |35 |28 | 29 | 30 | {{ child.name }} := 31 | , 33 | ... 34 | 3 |26 | 27 | 28 | 55 | 56 | 95 | -------------------------------------------------------------------------------- /src/renderer/components/connections/ConnectionItem.vue: -------------------------------------------------------------------------------- 1 | 2 |4 |25 | 9 | 14 | 17 | 21 | 24 |4 | 5 | 12 | 13 | 14 | 61 | 62 | 72 | -------------------------------------------------------------------------------- /src/renderer/components/connections/DatabaseItem.vue: -------------------------------------------------------------------------------- 1 | 2 |6 | 7 | 8 | 10 | 11 | 4 | 5 | 20 | 21 | 22 | 90 | 91 | 116 | -------------------------------------------------------------------------------- /src/renderer/components/connections/ModuleItem.vue: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 |13 | 14 | 15 | 16 | 18 | 19 | 4 | 5 | 6 | 21 | 22 | 23 | 80 | 81 | 96 | -------------------------------------------------------------------------------- /src/renderer/components/connections/TreeViewItem.vue: -------------------------------------------------------------------------------- 1 | 2 |7 | 8 | 11 | 12 | 13 | 14 |empty9 | 10 |15 | 17 | 18 | 19 | 20 | 3 |23 | 24 | 25 | 69 | 70 | 122 | -------------------------------------------------------------------------------- /src/renderer/components/dataTable/RenderColumns.vue: -------------------------------------------------------------------------------- 1 | 2 |6 |21 | 11 |12 | 14 |13 | 15 | 17 |{{ name }}16 |18 |20 |19 | 22 | 3 |16 | 17 | 18 | 58 | 59 | 93 | -------------------------------------------------------------------------------- /src/renderer/components/dataViewers/BytesViewer.vue: -------------------------------------------------------------------------------- 1 | 2 |4 |15 |{{ ri + lineNoOffset + 1 }}5 |10 |13 | 14 |11 | {{ row[column.name+'__count__'] }} items 12 | 3 |21 | 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 | 2 |4 | 5 |7 |0{{ (i-1).toString(16) }}6 |8 |20 |{{ li.toString(16).padStart(lineNoWidth, '0') }}09 |10 |14 |{{ byte }}13 |15 | {{ char }} 18 |19 |3 |10 | 11 | 12 | 30 | 31 | 42 | -------------------------------------------------------------------------------- /src/renderer/components/dataViewers/JsonViewer.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 47 | 48 | 66 | -------------------------------------------------------------------------------- /src/renderer/components/dataViewers/StrViewer.vue: -------------------------------------------------------------------------------- 1 | 2 |7 | {{ enumVal }} 8 |9 |7 |20 | 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 | 2 |8 |9 | {{ line }} 10 |11 |12 | 13 |14 | 19 | 18 |3 |54 | 55 | 56 | 107 | 108 | 156 | -------------------------------------------------------------------------------- /src/renderer/components/schemaView/SchemaDetails.vue: -------------------------------------------------------------------------------- 1 | 2 |4 |16 |6 | 7 | {{ item.required ? 'required' : '' }} 8 | {{ item.cardinality === 'MANY' ? 'multi' : '' }} 9 | 10 | 11 | {{ item.name }} 12 | {{ itemType }} 15 | 17 |53 |18 | inherited from {{ formatName(item.inherited_from) }} 20 |21 | 22 |23 |28 | 29 |24 | {{ formatName(annotation.name, ['std']) }} 25 | {{ annotation['@value'] }} 26 |27 |30 | 31 |52 |Default32 |{{ item.default }}33 | 34 | 35 |Computed36 |{{ item.expr }}37 | 38 | 39 |Constraints40 |{{ constraint.name }}({{ 43 | param['@value'] 44 | }}{{ i < constraint.params.length-1 ? ', ' : '' }})45 | 46 | 47 |Properties48 |50 | 51 | 3 |42 |4 | 5 | abstract 6 | type 7 | 8 | {{ formatName(obj.name) }} 9 |10 |11 | extends 12 | {{ formatName(name) }}, 14 | 15 |16 |17 | inherited by 18 | {{ formatName(name) }}, 20 | 21 |22 | 23 |24 |29 | 30 | 31 |25 | {{ formatName(annotation.name, ['std']) }} 26 | {{ annotation['@value'] }} 27 |28 |Properties32 |34 | 35 | 36 | 37 | Links38 |40 | 41 | 43 | No object selected 44 |45 | 46 | 47 | 48 | 71 | 72 | 119 | -------------------------------------------------------------------------------- /src/renderer/components/schemaView/SchemaLink.vue: -------------------------------------------------------------------------------- 1 | 2 |3 | 34 | 35 | 36 | 127 | 128 | 156 | -------------------------------------------------------------------------------- /src/renderer/components/schemaView/SchemaTable.vue: -------------------------------------------------------------------------------- 1 | 2 |4 | 28 | 30 | 31 | 32 |5 | 27 |6 | 7 | 10 |8 | 9 | 11 | 12 | 17 |13 | 14 | 15 | 16 | 18 | 19 | 26 |20 | 21 | 22 | 23 | 24 | 25 | 33 | 9 |15 | 16 | 17 | 65 | 66 | 97 | -------------------------------------------------------------------------------- /src/renderer/components/schemaView/SchemaTableItem.vue: -------------------------------------------------------------------------------- 1 | 2 |{{ tableName }}10 |11 |14 |13 | 8 |18 | 19 | 20 | 97 | 98 | 151 | -------------------------------------------------------------------------------- /src/renderer/components/settings/ConnectionPage.vue: -------------------------------------------------------------------------------- 1 | 2 |9 |12 |11 | 13 | 14 | 15 |{{ type === 'linkprop' ? '@' : '' }}{{ item.name }}16 |{{ itemType }}17 |3 |80 | 81 | 82 | 192 | 193 | 229 | -------------------------------------------------------------------------------- /src/renderer/components/settings/DumpPage.vue: -------------------------------------------------------------------------------- 1 | 2 |4 | 5 | 7 | 8 | 9 | 11 | 12 | 13 | 15 | 16 | 17 | 19 | 20 | 21 | 23 | 24 | 25 | 27 | 28 | 29 | 30 | 31 |65 | 79 |32 | 33 | 35 | 36 | 37 | 39 | 40 | 41 | 43 | 44 | 45 |64 |46 | 47 | 48 | 49 | 50 | 51 | 52 |54 | 55 | 56 | 57 | 58 | 59 | 60 | 62 | 63 | 3 |31 | 32 | 33 | 112 | 113 | 155 | -------------------------------------------------------------------------------- /src/renderer/components/settings/RestorePage.vue: -------------------------------------------------------------------------------- 1 | 2 |6 | 7 |16 | 30 |8 | 9 | 11 | 12 | 13 | 14 |15 | 3 |43 | 44 | 45 | 131 | 132 | 188 | -------------------------------------------------------------------------------- /src/renderer/components/tabViews/ConnSettingsView.vue: -------------------------------------------------------------------------------- 1 | 2 |6 | 7 |28 | 42 |8 | 9 | 10 | 11 | 12 | 13 | {{ formatBytes(fileInfo.fileSize) }} 14 | {{ fileInfo.dumpVersion }} 15 | {{ fileInfo.headerInfo.dumpTime.toUTCString() }} 16 | {{ fileInfo.headerInfo.serverVersion }} 17 |18 |19 | File Error {{ fileError }} 20 |21 | 22 | 23 | 24 | 26 | 27 |3 |25 | 26 | 27 | 71 | 72 | 102 | -------------------------------------------------------------------------------- /src/renderer/components/tabViews/SchemaView.vue: -------------------------------------------------------------------------------- 1 | 2 |4 |20 | 21 | 24 |5 |14 |11 | {{ page.name }} 12 |13 |15 |19 |16 | 18 |17 | 3 |33 | 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 |5 | 6 | 7 | 22 | 23 |8 |11 | 12 |9 | {{ module.loading === 0 ? 'Fetching schema' : 'Generating layout' }}10 |13 | No schema objects14 | 15 | 16 | 17 | 18 |19 | 20 | 21 | 24 | 32 | 25 | 26 | 27 | 31 |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 Mapany 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 | 2 | 7 | 8 | 9 | 29 | 30 | 34 | -------------------------------------------------------------------------------- /src/renderer/ui/FilePicker.vue: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 49 | 50 | 66 | -------------------------------------------------------------------------------- /src/renderer/ui/Icon.vue: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 16 | 17 | 25 | -------------------------------------------------------------------------------- /src/renderer/ui/Loading.vue: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 13 | 14 | 33 | -------------------------------------------------------------------------------- /src/renderer/ui/Progress.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 |7 | 8 | 9 | 27 | 28 | 91 | -------------------------------------------------------------------------------- /src/renderer/ui/ResizablePanel.vue: -------------------------------------------------------------------------------- 1 | 2 |8 |18 | 19 | 20 | 59 | 60 | 105 | -------------------------------------------------------------------------------- /src/renderer/ui/Select.vue: -------------------------------------------------------------------------------- 1 | 2 | 8 | 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 Set9 |11 | 12 | 13 |10 | 14 |16 | 17 |15 | () 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(/