Compare commits

...

31 Commits

Author SHA1 Message Date
Jan Prochazka 79fdde73ae v3.9.4-beta.3 2021-01-30 11:01:03 +01:00
Jan Prochazka 84e475192e upgraded electron - fixed problem with deleted localstorage 2021-01-30 10:56:22 +01:00
Jan Prochazka 3907b1ae8b v3.9.4-beta.2 2021-01-30 10:43:07 +01:00
Jan Prochazka dcfefc78a2 fixed save generated content in useEditorData 2021-01-30 10:37:28 +01:00
Jan Prochazka d3039a9248 useStorage improved - setter never changes (behaves more like useState) 2021-01-30 10:29:56 +01:00
Jan Prochazka 31dd80b79a fixed qorking with tabs 2021-01-30 09:41:50 +01:00
Jan Prochazka 8d6d1d979e v3.9.4-beta.1 2021-01-28 18:54:15 +01:00
Jan Prochazka fe1c5f5801 electron: save file to custom location 2021-01-28 18:52:59 +01:00
Jan Prochazka df976a84d2 electron menu sinplified 2021-01-28 16:56:47 +01:00
Jan Prochazka 420e94600e closed tab - show more info 2021-01-28 16:41:30 +01:00
Jan Prochazka 9940bd5177 upgrade mysql plugin dependency 2021-01-28 15:29:45 +01:00
Jan Prochazka 45d99a4126 timer labels in query design tab and shell tab 2021-01-28 13:00:24 +01:00
Jan Prochazka c2b7c775c0 code cleanup 2021-01-28 12:56:06 +01:00
Jan Prochazka 51ba9d3b5a statusbar - show query execution duration 2021-01-28 12:49:00 +01:00
Jan Prochazka 8396e726ec numbering tabs 2021-01-28 10:10:41 +01:00
Jan Prochazka cb67b57faf numbering tabs fix 2021-01-28 10:07:02 +01:00
Jan Prochazka 99381536d7 numbering tabs 2021-01-28 10:05:27 +01:00
Jan Prochazka a9cb9f1874 style fix 2021-01-28 09:48:33 +01:00
Jan Prochazka 420a58380a upgraded required plugin-postgres version 2021-01-28 09:44:48 +01:00
Jan Prochazka 75ca3cbb11 packages-tools v1.0.8 2021-01-28 09:12:38 +01:00
Jan Prochazka a5c1966a94 makeUniqueColumnNames function 2021-01-28 09:12:10 +01:00
Jan Prochazka ca4ff95316 optimalized connection ping 2021-01-28 08:16:31 +01:00
Jan Prochazka a3294950a4 v3.9.3 2021-01-26 20:40:15 +01:00
Jan Prochazka 29355a6d3e v3.9.3-beta.1 2021-01-26 20:31:10 +01:00
Jan Prochazka add0ba09c3 fix 2021-01-26 20:30:38 +01:00
Jan Prochazka 005ae87309 v3.9.2 2021-01-25 17:46:53 +01:00
Jan Prochazka 5f372a1d0f v3.9.2-beta.1 2021-01-25 17:33:42 +01:00
Jan Prochazka ecce75960a fix in open ref table 2021-01-25 17:33:16 +01:00
Jan Prochazka 72cc510c64 improved error boundary 2021-01-25 17:20:17 +01:00
Jan Prochazka 7e39b8c2a0 v3.9.1 2021-01-25 06:52:06 +01:00
Jan Prochazka ed4ef4d999 v3.9.1-beta.2 2021-01-24 17:26:28 +01:00
41 changed files with 586 additions and 207 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "dbgate",
"version": "3.9.1-beta.1",
"version": "3.9.4-beta.3",
"private": true,
"author": "Jan Prochazka <jenasoft.database@gmail.com>",
"description": "Opensource database administration tool",
@@ -68,7 +68,7 @@
"devDependencies": {
"copyfiles": "^2.2.0",
"cross-env": "^6.0.3",
"electron": "11.1.1",
"electron": "11.2.1",
"electron-builder": "22.9.1"
},
"optionalDependencies": {
+26 -18
View File
@@ -51,12 +51,34 @@ function buildMenu() {
mainWindow.webContents.executeJavaScript(`dbgate_newQuery()`);
},
},
{
label: 'Open file',
click() {
mainWindow.webContents.executeJavaScript(`dbgate_openFile()`);
},
},
{
label: 'Close all tabs',
click() {
mainWindow.webContents.executeJavaScript('dbgate_closeAll()');
},
},
{ type: 'separator' },
{ role: 'minimize' },
{ role: 'close' },
],
},
{
label: 'Edit',
submenu: [{ role: 'copy' }, { role: 'paste' }],
},
// {
// label: 'Edit',
// submenu: [
// { role: 'undo' },
// { role: 'redo' },
// { type: 'separator' },
// { role: 'cut' },
// { role: 'copy' },
// { role: 'paste' },
// ],
// },
{
label: 'View',
submenu: [
@@ -71,20 +93,6 @@ function buildMenu() {
{ role: 'togglefullscreen' },
],
},
{
role: 'window',
submenu: [
{
label: 'Close all tabs',
click() {
mainWindow.webContents.executeJavaScript('dbgate_closeAll()');
},
},
{ type: 'separator' },
{ role: 'minimize' },
{ role: 'close' },
],
},
{
role: 'help',
submenu: [
+4 -4
View File
@@ -717,10 +717,10 @@ electron-updater@^4.3.5:
lodash.isequal "^4.5.0"
semver "^7.3.2"
electron@11.1.1:
version "11.1.1"
resolved "https://registry.yarnpkg.com/electron/-/electron-11.1.1.tgz#188f036f8282798398dca9513e9bb3b10213e3aa"
integrity sha512-tlbex3xosJgfileN6BAQRotevPRXB/wQIq48QeQ08tUJJrXwE72c8smsM/hbHx5eDgnbfJ2G3a60PmRjHU2NhA==
electron@11.2.1:
version "11.2.1"
resolved "https://registry.yarnpkg.com/electron/-/electron-11.2.1.tgz#8641dd1a62911a1144e0c73c34fd9f37ccc65c2b"
integrity sha512-Im1y29Bnil+Nzs+FCTq01J1OtLbs+2ZGLLllaqX/9n5GgpdtDmZhS/++JHBsYZ+4+0n7asO+JKQgJD+CqPClzg==
dependencies:
"@electron/get" "^1.0.1"
"@types/node" "^12.0.12"
+5
View File
@@ -78,6 +78,11 @@ module.exports = {
}
},
saveAs_meta: 'post',
async saveAs({ filePath, data, format }) {
await fs.writeFile(filePath, serialize(format, data));
},
favorites_meta: 'get',
async favorites() {
if (!hasPermission(`files/favorites/read`)) return [];
+2 -2
View File
@@ -29,8 +29,8 @@ const hasPermission = require('../utility/hasPermission');
const preinstallPluginMinimalVersions = {
'dbgate-plugin-mssql': '1.0.10',
'dbgate-plugin-mysql': '1.0.3',
'dbgate-plugin-postgres': '1.0.2',
'dbgate-plugin-mysql': '1.0.4',
'dbgate-plugin-postgres': '1.0.3',
'dbgate-plugin-csv': '1.0.8',
'dbgate-plugin-excel': '1.0.6',
};
@@ -8,6 +8,7 @@ const lock = new AsyncLock();
module.exports = {
opened: [],
closed: {},
lastPinged: {},
handle_databases(conid, { databases }) {
const existing = this.opened.find(x => x.conid == conid);
@@ -88,7 +89,12 @@ module.exports = {
ping_meta: 'post',
async ping({ connections }) {
await Promise.all(
connections.map(async conid => {
_.uniq(connections).map(async conid => {
const last = this.lastPinged[conid];
if (last && new Date().getTime() - last < 30 * 1000) {
return Promise.resolve();
}
this.lastPinged[conid] = new Date().getTime();
const opened = await this.ensureOpened(conid);
opened.subprocess.send({ msgtype: 'ping' });
})
+1 -1
View File
@@ -1,5 +1,5 @@
{
"version": "1.0.7",
"version": "1.0.8",
"name": "dbgate-tools",
"main": "lib/index.js",
"typings": "lib/index.d.ts",
+12
View File
@@ -49,3 +49,15 @@ export function findObjectLike(
export function findForeignKeyForColumn(table: TableInfo, column: ColumnInfo) {
return (table.foreignKeys || []).find(fk => fk.columns.find(col => col.columnName == column.columnName));
}
export function makeUniqueColumnNames(res: ColumnInfo[]) {
const usedNames = new Set();
for (let i = 0; i < res.length; i++) {
if (usedNames.has(res[i].columnName)) {
let suffix = 2;
while (usedNames.has(`${res[i].columnName}${suffix}`)) suffix++;
res[i].columnName = `${res[i].columnName}${suffix}`;
}
usedNames.add(res[i].columnName);
}
}
+36 -33
View File
@@ -16,7 +16,7 @@ import DragAndDropFileTarget from './DragAndDropFileTarget';
import { useUploadsZone } from './utility/UploadsProvider';
import useTheme from './theme/useTheme';
import { MenuLayer } from './modals/showMenu';
import ErrorBoundary from './utility/ErrorBoundary';
import ErrorBoundary, { ErrorBoundaryTest } from './utility/ErrorBoundary';
const BodyDiv = styled.div`
position: fixed;
@@ -100,45 +100,48 @@ export default function Screen() {
? dimensions.widgetMenu.iconSize + leftPanelWidth + dimensions.splitter.thickness
: dimensions.widgetMenu.iconSize;
const toolbarPortalRef = React.useRef();
const statusbarPortalRef = React.useRef();
const onSplitDown = useSplitterDrag('clientX', diff => setLeftPanelWidth(v => v + diff));
const { getRootProps, getInputProps, isDragActive } = useUploadsZone();
return (
<div {...getRootProps()}>
<ToolBarDiv theme={theme}>
<ToolBar toolbarPortalRef={toolbarPortalRef} />
</ToolBarDiv>
<IconBar theme={theme}>
<WidgetIconPanel />
</IconBar>
{!!currentWidget && (
<LeftPanel theme={theme}>
<ErrorBoundary>
<WidgetContainer />
</ErrorBoundary>
</LeftPanel>
)}
{!!currentWidget && (
<ScreenHorizontalSplitHandle
onMouseDown={onSplitDown}
theme={theme}
style={{ left: leftPanelWidth + dimensions.widgetMenu.iconSize }}
/>
)}
<TabsPanelContainer contentLeft={contentLeft} theme={theme}>
<TabsPanel></TabsPanel>
</TabsPanelContainer>
<BodyDiv contentLeft={contentLeft} theme={theme}>
<TabContent toolbarPortalRef={toolbarPortalRef} />
</BodyDiv>
<StausBarContainer theme={theme}>
<StatusBar />
</StausBarContainer>
<ModalLayer />
<MenuLayer />
<ErrorBoundary>
<ToolBarDiv theme={theme}>
<ToolBar toolbarPortalRef={toolbarPortalRef} />
</ToolBarDiv>
<IconBar theme={theme}>
<WidgetIconPanel />
</IconBar>
{!!currentWidget && (
<LeftPanel theme={theme}>
<ErrorBoundary>
<WidgetContainer />
</ErrorBoundary>
</LeftPanel>
)}
{!!currentWidget && (
<ScreenHorizontalSplitHandle
onMouseDown={onSplitDown}
theme={theme}
style={{ left: leftPanelWidth + dimensions.widgetMenu.iconSize }}
/>
)}
<TabsPanelContainer contentLeft={contentLeft} theme={theme}>
<TabsPanel></TabsPanel>
</TabsPanelContainer>
<BodyDiv contentLeft={contentLeft} theme={theme}>
<TabContent toolbarPortalRef={toolbarPortalRef} statusbarPortalRef={statusbarPortalRef} />
</BodyDiv>
<StausBarContainer theme={theme}>
<StatusBar statusbarPortalRef={statusbarPortalRef} />
</StausBarContainer>
<ModalLayer />
<MenuLayer />
<DragAndDropFileTarget inputProps={getInputProps()} isDragActive={isDragActive} />
<DragAndDropFileTarget inputProps={getInputProps()} isDragActive={isDragActive} />
</ErrorBoundary>
</div>
);
}
+10 -3
View File
@@ -18,12 +18,18 @@ const TabContainerStyled = styled.div`
`;
function TabContainer({ TabComponent, ...props }) {
const { tabVisible, tabid, toolbarPortalRef } = props;
const { tabVisible, tabid, toolbarPortalRef, statusbarPortalRef } = props;
return (
// @ts-ignore
<TabContainerStyled tabVisible={tabVisible}>
<ErrorBoundary>
<TabComponent {...props} tabid={tabid} tabVisible={tabVisible} toolbarPortalRef={toolbarPortalRef} />
<TabComponent
{...props}
tabid={tabid}
tabVisible={tabVisible}
toolbarPortalRef={toolbarPortalRef}
statusbarPortalRef={statusbarPortalRef}
/>
</ErrorBoundary>
</TabContainerStyled>
);
@@ -42,7 +48,7 @@ function createTabComponent(selectedTab) {
return null;
}
export default function TabContent({ toolbarPortalRef }) {
export default function TabContent({ toolbarPortalRef, statusbarPortalRef }) {
const files = useOpenedTabs();
const [mountedTabs, setMountedTabs] = React.useState({});
@@ -84,6 +90,7 @@ export default function TabContent({ toolbarPortalRef }) {
tabid={tabid}
tabVisible={tabVisible}
toolbarPortalRef={toolbarPortalRef}
statusbarPortalRef={statusbarPortalRef}
TabComponent={TabComponent}
/>
);
+11 -2
View File
@@ -124,6 +124,15 @@ function getDbIcon(key) {
return 'icon file';
}
function buildTooltip(tab) {
let res = tab.tooltip;
if (tab.props && tab.props.savedFilePath) {
if (res) res += '\n';
res += tab.props.savedFilePath;
}
return res;
}
export default function TabsPanel() {
// const formatDbKey = (conid, database) => `${database}-${conid}`;
const theme = useTheme();
@@ -249,10 +258,10 @@ export default function TabsPanel() {
<FontIcon icon={getDbIcon(dbKey)} /> {tabsByDb[dbKey][0].tabDbName}
</DbNameWrapper>
<DbGroupHandler>
{_.sortBy(tabsByDb[dbKey], 'title').map(tab => (
{_.sortBy(tabsByDb[dbKey], ['title', 'tabid']).map(tab => (
<FileTabItem
{...tab}
title={tab.tooltip}
title={buildTooltip(tab)}
key={tab.tabid}
theme={theme}
onClick={e => handleTabClick(e, tab.tabid)}
+30 -26
View File
@@ -49,6 +49,7 @@ export function AppObjectCore({
extInfo = undefined,
statusTitle = undefined,
disableHover = false,
children = null,
Menu = undefined,
...other
}) {
@@ -63,31 +64,34 @@ export function AppObjectCore({
};
return (
<AppObjectDiv
onContextMenu={handleContextMenu}
onClick={() => {
if (onClick) onClick(data);
if (onClick2) onClick2(data);
if (onClick3) onClick3(data);
}}
theme={theme}
isBold={isBold}
draggable
onDragStart={e => {
e.dataTransfer.setData('app_object_drag_data', JSON.stringify(data));
}}
disableHover={disableHover}
{...other}
>
{prefix}
<IconWrap>{isBusy ? <FontIcon icon="icon loading" /> : <FontIcon icon={icon} />}</IconWrap>
{title}
{statusIcon && (
<StatusIconWrap>
<FontIcon icon={statusIcon} title={statusTitle} />
</StatusIconWrap>
)}
{extInfo && <ExtInfoWrap theme={theme}>{extInfo}</ExtInfoWrap>}
</AppObjectDiv>
<>
<AppObjectDiv
onContextMenu={handleContextMenu}
onClick={() => {
if (onClick) onClick(data);
if (onClick2) onClick2(data);
if (onClick3) onClick3(data);
}}
theme={theme}
isBold={isBold}
draggable
onDragStart={e => {
e.dataTransfer.setData('app_object_drag_data', JSON.stringify(data));
}}
disableHover={disableHover}
{...other}
>
{prefix}
<IconWrap>{isBusy ? <FontIcon icon="icon loading" /> : <FontIcon icon={icon} />}</IconWrap>
{title}
{statusIcon && (
<StatusIconWrap>
<FontIcon icon={statusIcon} title={statusTitle} />
</StatusIconWrap>
)}
{extInfo && <ExtInfoWrap theme={theme}>{extInfo}</ExtInfoWrap>}
</AppObjectDiv>
{children}
</>
);
}
+22 -8
View File
@@ -5,6 +5,14 @@ import { DropDownMenuItem } from '../modals/DropDownMenu';
import { useSetOpenedTabs } from '../utility/globalState';
import { AppObjectCore } from './AppObjectCore';
import { setSelectedTabFunc } from '../utility/common';
import styled from 'styled-components';
import { FontIcon } from '../icons';
import useTheme from '../theme/useTheme';
const InfoDiv = styled.div`
margin-left: 30px;
color: ${props => props.theme.left_font3};
`;
function Menu({ data }) {
const setOpenedTabs = useSetOpenedTabs();
@@ -25,17 +33,16 @@ function Menu({ data }) {
function ClosedTabAppObject({ data, commonProps }) {
const { tabid, props, selected, icon, title, closedTime, busy } = data;
const setOpenedTabs = useSetOpenedTabs();
const theme = useTheme();
const onClick = () => {
setOpenedTabs(files =>
setSelectedTabFunc(
files.map(
x => ({
...x,
closedTime: x.tabid == tabid ? undefined : x.closedTime,
}),
tabid
)
files.map(x => ({
...x,
closedTime: x.tabid == tabid ? undefined : x.closedTime,
})),
tabid
)
);
};
@@ -50,7 +57,14 @@ function ClosedTabAppObject({ data, commonProps }) {
onClick={onClick}
isBusy={busy}
Menu={Menu}
/>
>
{data.props && data.props.database && (
<InfoDiv theme={theme}>
<FontIcon icon="icon database" /> {data.props.database}
</InfoDiv>
)}
{data.contentPreview && <InfoDiv theme={theme}>{data.contentPreview}</InfoDiv>}
</AppObjectCore>
);
}
@@ -40,7 +40,7 @@ function Menu({ data }) {
setOpenedConnections(list => list.filter(x => x != data._id));
};
const handleConnect = () => {
setOpenedConnections(list => [...list, data._id]);
setOpenedConnections(list => _.uniq([...list, data._id]));
};
return (
<>
@@ -72,7 +72,7 @@ function ConnectionAppObject({ data, commonProps }) {
const extensions = useExtensions();
const isBold = _.get(currentDatabase, 'connection._id') == _id;
const onClick = () => setOpenedConnections(c => [...c, _id]);
const onClick = () => setOpenedConnections(c => _.uniq([...c, _id]));
let statusIcon = null;
let statusTitle = null;
+1 -1
View File
@@ -20,7 +20,7 @@ function Menu({ data }) {
const handleNewQuery = () => {
openNewTab({
title: 'Query',
title: 'Query #',
icon: 'img sql-file',
tooltip,
tabComponent: 'QueryTab',
@@ -149,7 +149,7 @@ export async function openDatabaseObjectDetail(
openNewTab(
{
title: pureName,
title: sqlTemplate ? 'Query #' : pureName,
tooltip,
icon: sqlTemplate ? 'img sql-file' : icons[objectTypeField],
tabComponent: sqlTemplate ? 'QueryTab' : tabComponent,
@@ -245,7 +245,7 @@ function Menu({ data }) {
} else if (menu.isQueryDesigner) {
openNewTab(
{
title: data.pureName,
title: 'Query #',
icon: 'img query-design',
tabComponent: 'QueryDesignTab',
props: {
@@ -104,7 +104,7 @@ export function SavedSqlFileAppObject({ data, commonProps }) {
openNewTab(
{
title: 'Shell',
title: 'Shell #',
icon: 'img shell',
tabComponent: 'ShellTab',
},
@@ -9,6 +9,7 @@ import { openDatabaseObjectDetail } from '../appobj/DatabaseObjectAppObject';
import { useSetOpenedTabs } from '../utility/globalState';
import { FontIcon } from '../icons';
import useTheme from '../theme/useTheme';
import useOpenNewTab from '../utility/useOpenNewTab';
const HeaderDiv = styled.div`
display: flex;
@@ -52,11 +53,11 @@ export default function ColumnHeaderControl({
}) {
const onResizeDown = useSplitterDrag('clientX', onResize);
const { foreignKey } = column;
const setOpenedTabs = useSetOpenedTabs();
const openNewTab = useOpenNewTab();
const theme = useTheme();
const openReferencedTable = () => {
openDatabaseObjectDetail(setOpenedTabs, 'TableDataTab', null, {
openDatabaseObjectDetail(openNewTab, 'TableDataTab', null, {
schemaName: foreignKey.refSchemaName,
pureName: foreignKey.refTableName,
conid,
+3 -3
View File
@@ -312,7 +312,7 @@ export default function DataGridCore(props) {
}
}
: null,
[formViewAvailable, display]
[formViewAvailable, display, openNewTab]
);
if (!columns || columns.length == 0) return <LoadingInfo wrapper message="Waiting for structure" />;
@@ -353,7 +353,7 @@ export default function DataGridCore(props) {
const handleOpenFreeTable = () => {
openNewTab(
{
title: 'selection',
title: 'Data #',
icon: 'img free-table',
tabComponent: 'FreeTableTab',
props: {},
@@ -365,7 +365,7 @@ export default function DataGridCore(props) {
const handleOpenChart = () => {
openNewTab(
{
title: 'Chart',
title: 'Chart #',
icon: 'img chart',
tabComponent: 'ChartTab',
props: {},
+2 -2
View File
@@ -83,7 +83,7 @@ export default function SqlDataGridCore(props) {
function openActiveChart() {
openNewTab(
{
title: 'Chart',
title: 'Chart #',
icon: 'img chart',
tabComponent: 'ChartTab',
props: {
@@ -104,7 +104,7 @@ export default function SqlDataGridCore(props) {
function openQuery() {
openNewTab(
{
title: 'Query',
title: 'Query #',
icon: 'img sql-file',
tabComponent: 'QueryTab',
props: {
+1 -1
View File
@@ -15,7 +15,7 @@ const Container = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
background: #ddeeee;
background: ${props => props.theme.gridheader_background_cyan[0]};
height: ${dimensions.toolBar.height}px;
min-height: ${dimensions.toolBar.height}px;
overflow: hidden;
@@ -6,7 +6,7 @@ export default function useNewFreeTable() {
return ({ title = undefined, ...props } = {}) =>
openNewTab({
title: title || 'Table',
title: title || 'Data #',
icon: 'img free-table',
tabComponent: 'FreeTableTab',
props,
+1 -1
View File
@@ -100,7 +100,7 @@ function GenerateSctriptButton({ modalState }) {
const code = await createImpExpScript(extensions, values);
openNewTab(
{
title: 'Shell',
title: 'Shell #',
icon: 'img shell',
tabComponent: 'ShellTab',
},
+55 -2
View File
@@ -6,14 +6,52 @@ import ModalHeader from './ModalHeader';
import ModalContent from './ModalContent';
import ModalFooter from './ModalFooter';
import { FormProvider } from '../utility/FormProvider';
import FormStyledButton from '../widgets/FormStyledButton';
import getElectron from '../utility/getElectron';
export default function SaveFileModal({
data,
folder,
format,
modalState,
name,
fileExtension,
filePath,
onSave = undefined,
}) {
const electron = getElectron();
export default function SaveFileModal({ data, folder, format, modalState, name, onSave = undefined }) {
const handleSubmit = async values => {
const { name } = values;
await axios.post('files/save', { folder, file: name, data, format });
modalState.close();
if (onSave) onSave(name);
if (onSave) {
onSave(name, {
savedFile: name,
savedFolder: folder,
savedFilePath: null,
});
}
};
const handleSaveAs = async filePath => {
const path = window.require('path');
const parsed = path.parse(filePath);
if (!parsed.ext) filePath += `.${fileExtension}`;
await axios.post('files/save-as', { filePath, data, format });
modalState.close();
if (onSave) {
onSave(parsed.name, {
savedFile: null,
savedFolder: null,
savedFilePath: filePath,
});
}
};
return (
<ModalBase modalState={modalState}>
<ModalHeader modalState={modalState}>Save file</ModalHeader>
@@ -23,6 +61,21 @@ export default function SaveFileModal({ data, folder, format, modalState, name,
</ModalContent>
<ModalFooter>
<FormSubmit value="Save" onClick={handleSubmit} />
{electron && (
<FormStyledButton
type="button"
value="Save to disk"
onClick={() => {
const file = electron.remote.dialog.showSaveDialogSync(electron.remote.getCurrentWindow(), {
filters: { name: `${fileExtension.toUpperCase()} files`, extensions: [fileExtension] },
defaultPath: filePath,
});
if (file) {
handleSaveAs(file);
}
}}
/>
)}
</ModalFooter>
</FormProvider>
</ModalBase>
+8 -6
View File
@@ -4,22 +4,22 @@ import { useOpenedTabs, useSetOpenedTabs } from '../utility/globalState';
import keycodes from '../utility/keycodes';
import SaveFileModal from './SaveFileModal';
export default function SaveTabModal({ data, folder, format, modalState, tabid, tabVisible }) {
export default function SaveTabModal({ data, folder, format, modalState, tabid, tabVisible, fileExtension }) {
const setOpenedTabs = useSetOpenedTabs();
const openedTabs = useOpenedTabs();
const { savedFile } = openedTabs.find(x => x.tabid == tabid).props || {};
const onSave = name =>
const { savedFile, savedFilePath } = openedTabs.find(x => x.tabid == tabid).props || {};
const onSave = (title, newProps) => {
changeTab(tabid, setOpenedTabs, tab => ({
...tab,
title: name,
title,
props: {
...tab.props,
savedFile: name,
savedFolder: folder,
savedFormat: format,
...newProps,
},
}));
};
const handleKeyboard = React.useCallback(
e => {
@@ -47,6 +47,8 @@ export default function SaveTabModal({ data, folder, format, modalState, tabid,
format={format}
modalState={modalState}
name={savedFile || 'newFile'}
filePath={savedFilePath}
fileExtension={fileExtension}
onSave={onSave}
/>
);
+2 -2
View File
@@ -14,7 +14,7 @@ export default function useNewQuery() {
return ({ title = undefined, initialData = undefined, ...props } = {}) =>
openNewTab(
{
title: title || 'Query',
title: title || 'Query #',
icon: 'img sql-file',
tooltip,
tabComponent: 'QueryTab',
@@ -40,7 +40,7 @@ export function useNewQueryDesign() {
return ({ title = undefined, initialData = undefined, ...props } = {}) =>
openNewTab(
{
title: title || 'Query',
title: title || 'Query #',
icon: 'img query-design',
tooltip,
tabComponent: 'QueryDesignTab',
+1
View File
@@ -63,6 +63,7 @@ export default function ChartTab({ tabVisible, toolbarPortalRef, conid, database
format="json"
folder="charts"
tabid={tabid}
fileExtension='chart'
/>
{toolbarPortalRef &&
toolbarPortalRef.current &&
@@ -75,6 +75,7 @@ export default function MarkdownEditorTab({ tabid, tabVisible, toolbarPortalRef,
format="text"
folder="markdown"
tabid={tabid}
fileExtension='md'
/>
</>
);
+20 -1
View File
@@ -25,8 +25,18 @@ import QueryDesignColumns from '../designer/QueryDesignColumns';
import { findEngineDriver } from 'dbgate-tools';
import { generateDesignedQuery } from '../designer/designerTools';
import useUndoReducer from '../utility/useUndoReducer';
import { StatusBarItem } from '../widgets/StatusBar';
import useTimerLabel from '../utility/useTimerLabel';
export default function QueryDesignTab({ tabid, conid, database, tabVisible, toolbarPortalRef, ...other }) {
export default function QueryDesignTab({
tabid,
conid,
database,
tabVisible,
toolbarPortalRef,
statusbarPortalRef,
...other
}) {
const [sessionId, setSessionId] = React.useState(null);
const [visibleResultTabs, setVisibleResultTabs] = React.useState(false);
const [executeNumber, setExecuteNumber] = React.useState(0);
@@ -49,6 +59,7 @@ export default function QueryDesignTab({ tabid, conid, database, tabVisible, too
},
{ mergeNearActions: true }
);
const timerLabel = useTimerLabel();
React.useEffect(() => {
// @ts-ignore
@@ -61,6 +72,7 @@ export default function QueryDesignTab({ tabid, conid, database, tabVisible, too
const handleSessionDone = React.useCallback(() => {
setBusy(false);
timerLabel.stop();
}, []);
const generatePreview = (value, engine) => {
@@ -114,6 +126,7 @@ export default function QueryDesignTab({ tabid, conid, database, tabVisible, too
setSessionId(sesid);
}
setBusy(true);
timerLabel.start();
await axios.post('sessions/execute-query', {
sesid,
sql: sqlPreview,
@@ -126,6 +139,7 @@ export default function QueryDesignTab({ tabid, conid, database, tabVisible, too
});
setSessionId(null);
setBusy(false);
timerLabel.stop();
};
const handleKeyDown = React.useCallback(
@@ -200,6 +214,10 @@ export default function QueryDesignTab({ tabid, conid, database, tabVisible, too
/>,
toolbarPortalRef.current
)}
{statusbarPortalRef &&
statusbarPortalRef.current &&
tabVisible &&
ReactDOM.createPortal(<StatusBarItem>{timerLabel.text}</StatusBarItem>, statusbarPortalRef.current)}
<SaveTabModal
modalState={saveFileModalState}
tabVisible={tabVisible}
@@ -207,6 +225,7 @@ export default function QueryDesignTab({ tabid, conid, database, tabVisible, too
format="json"
folder="query"
tabid={tabid}
fileExtension='qdesign'
/>
</>
);
+56 -1
View File
@@ -21,8 +21,37 @@ import useEditorData from '../utility/useEditorData';
import applySqlTemplate from '../utility/applySqlTemplate';
import LoadingInfo from '../widgets/LoadingInfo';
import useExtensions from '../utility/useExtensions';
import useTimerLabel from '../utility/useTimerLabel';
import { StatusBarItem } from '../widgets/StatusBar';
export default function QueryTab({ tabid, conid, database, initialArgs, tabVisible, toolbarPortalRef, ...other }) {
function createSqlPreview(sql) {
if (!sql) return undefined;
let data = sql.substring(0, 500);
data = data.replace(/\[[^\]]+\]\./g, '');
data = data.replace(/\[a-zA-Z0-9_]+\./g, '');
data = data.replace(/\/\*.*\*\//g, '');
data = data.replace(/[\[\]]/g, '');
data = data.replace(/--[^\n]*\n/g, '');
for (let step = 1; step <= 5; step++) {
data = data.replace(/\([^\(^\)]+\)/g, '');
}
data = data.replace(/\s+/g, ' ');
data = data.trim();
data = data.replace(/^(.{50}[^\s]*).*/, '$1');
return data;
}
export default function QueryTab({
tabid,
conid,
database,
initialArgs,
tabVisible,
toolbarPortalRef,
statusbarPortalRef,
...other
}) {
const [sessionId, setSessionId] = React.useState(null);
const [visibleResultTabs, setVisibleResultTabs] = React.useState(false);
const [executeNumber, setExecuteNumber] = React.useState(0);
@@ -31,6 +60,7 @@ export default function QueryTab({ tabid, conid, database, initialArgs, tabVisib
const [busy, setBusy] = React.useState(false);
const saveFileModalState = useModalState();
const extensions = useExtensions();
const timerLabel = useTimerLabel();
const { editorData, setEditorData, isLoading } = useEditorData({
tabid,
loadFromArgs:
@@ -43,6 +73,7 @@ export default function QueryTab({ tabid, conid, database, initialArgs, tabVisib
const handleSessionDone = React.useCallback(() => {
setBusy(false);
timerLabel.stop();
}, []);
React.useEffect(() => {
@@ -61,6 +92,23 @@ export default function QueryTab({ tabid, conid, database, initialArgs, tabVisib
useUpdateDatabaseForTab(tabVisible, conid, database);
const connection = useConnectionInfo({ conid });
const updateContentPreviewDebounced = React.useRef(
_.debounce(
// @ts-ignore
sql =>
changeTab(tabid, setOpenedTabs, tab => ({
...tab,
contentPreview: createSqlPreview(sql),
})),
500
)
);
React.useEffect(() => {
// @ts-ignore
updateContentPreviewDebounced.current(editorData);
}, [editorData]);
const handleExecute = async () => {
if (busy) return;
setExecuteNumber(num => num + 1);
@@ -77,6 +125,7 @@ export default function QueryTab({ tabid, conid, database, initialArgs, tabVisib
setSessionId(sesid);
}
setBusy(true);
timerLabel.start();
await axios.post('sessions/execute-query', {
sesid,
sql: selectedText || editorData,
@@ -95,6 +144,7 @@ export default function QueryTab({ tabid, conid, database, initialArgs, tabVisib
});
setSessionId(null);
setBusy(false);
timerLabel.stop();
};
const handleKeyDown = (data, hash, keyString, keyCode, event) => {
@@ -167,6 +217,10 @@ export default function QueryTab({ tabid, conid, database, initialArgs, tabVisib
/>,
toolbarPortalRef.current
)}
{statusbarPortalRef &&
statusbarPortalRef.current &&
tabVisible &&
ReactDOM.createPortal(<StatusBarItem>{timerLabel.text}</StatusBarItem>, statusbarPortalRef.current)}
<SaveTabModal
modalState={saveFileModalState}
tabVisible={tabVisible}
@@ -174,6 +228,7 @@ export default function QueryTab({ tabid, conid, database, initialArgs, tabVisib
format="text"
folder="sql"
tabid={tabid}
fileExtension='sql'
/>
</>
);
+12 -1
View File
@@ -16,16 +16,19 @@ import useEditorData from '../utility/useEditorData';
import SaveTabModal from '../modals/SaveTabModal';
import useModalState from '../modals/useModalState';
import LoadingInfo from '../widgets/LoadingInfo';
import useTimerLabel from '../utility/useTimerLabel';
import { StatusBarItem } from '../widgets/StatusBar';
const configRegex = /\s*\/\/\s*@ImportExportConfigurator\s*\n\s*\/\/\s*(\{[^\n]+\})\n/;
const requireRegex = /\s*(\/\/\s*@require\s+[^\n]+)\n/g;
const initRegex = /([^\n]+\/\/\s*@init)/g;
export default function ShellTab({ tabid, tabVisible, toolbarPortalRef, ...other }) {
export default function ShellTab({ tabid, tabVisible, toolbarPortalRef, statusbarPortalRef, ...other }) {
const [busy, setBusy] = React.useState(false);
const showModal = useShowModal();
const { editorData, setEditorData, isLoading } = useEditorData({ tabid });
const saveFileModalState = useModalState();
const timerLabel = useTimerLabel();
const setOpenedTabs = useSetOpenedTabs();
@@ -42,6 +45,7 @@ export default function ShellTab({ tabid, tabVisible, toolbarPortalRef, ...other
const handleRunnerDone = React.useCallback(() => {
setBusy(false);
timerLabel.stop();
}, []);
React.useEffect(() => {
@@ -69,12 +73,14 @@ export default function ShellTab({ tabid, tabVisible, toolbarPortalRef, ...other
runid = resp.data.runid;
setRunnerId(runid);
setBusy(true);
timerLabel.start();
};
const handleCancel = () => {
axios.post('runners/cancel', {
runid: runnerId,
});
timerLabel.stop();
};
const handleKeyDown = (data, hash, keyString, keyCode, event) => {
@@ -128,6 +134,10 @@ export default function ShellTab({ tabid, tabVisible, toolbarPortalRef, ...other
/>,
toolbarPortalRef.current
)}
{statusbarPortalRef &&
statusbarPortalRef.current &&
tabVisible &&
ReactDOM.createPortal(<StatusBarItem>{timerLabel.text}</StatusBarItem>, statusbarPortalRef.current)}
<SaveTabModal
modalState={saveFileModalState}
tabVisible={tabVisible}
@@ -135,6 +145,7 @@ export default function ShellTab({ tabid, tabVisible, toolbarPortalRef, ...other
format="text"
folder="shell"
tabid={tabid}
fileExtension='js'
/>
</>
);
+13 -4
View File
@@ -7,9 +7,11 @@ export default function ConnectionsPinger({ children }) {
const openedConnections = useOpenedConnections();
const currentDatabase = useCurrentDatabase();
const doPing = () => {
const doServerPing = () => {
axios.post('server-connections/ping', { connections: openedConnections });
};
const doDatabasePing = () => {
const database = _.get(currentDatabase, 'name');
const conid = _.get(currentDatabase, 'connection._id');
if (conid && database) {
@@ -18,9 +20,16 @@ export default function ConnectionsPinger({ children }) {
};
React.useEffect(() => {
doPing();
const handle = window.setInterval(doPing, 30 * 1000);
doServerPing();
const handle = window.setInterval(doServerPing, 30 * 1000);
return () => window.clearInterval(handle);
}, [openedConnections, currentDatabase]);
}, [openedConnections]);
React.useEffect(() => {
doDatabasePing();
const handle = window.setInterval(doDatabasePing, 30 * 1000);
return () => window.clearInterval(handle);
}, [currentDatabase]);
return children;
}
+66 -12
View File
@@ -1,5 +1,58 @@
import React from 'react';
import _ from 'lodash';
import ErrorInfo from '../widgets/ErrorInfo';
import styled from 'styled-components';
import localforage from 'localforage';
import FormStyledButton from '../widgets/FormStyledButton';
const Stack = styled.pre`
margin-left: 20px;
`;
const WideButton = styled(FormStyledButton)`
width: 150px;
`;
const Info = styled.div`
margin: 20px;
`;
export function ErrorScreen({ error }) {
let message;
try {
message = 'Error: ' + (error.message || error).toString();
} catch (e) {
message = 'DbGate internal error detected';
}
const handleReload = () => {
window.location.reload();
};
const handleClearReload = async () => {
localStorage.clear();
try {
await localforage.clear();
} catch (err) {
console.error('Error clearing app data', err);
}
window.location.reload();
};
return (
<div>
<ErrorInfo message={message} />
<WideButton type="button" value="Reload app" onClick={handleReload} />
<WideButton type="button" value="Clear and reload" onClick={handleClearReload} />
<Info>
If reloading doesn&apos;t help, you can try to clear all browser data (opened tabs, history of opened windows)
and reload app. Your connections and saved files are not touched by this clear operation. <br />
If you see this error in the tab, closing the tab should solve the problem.
</Info>
<Stack>{_.isString(error.stack) ? error.stack : null}</Stack>
</div>
);
}
export default class ErrorBoundary extends React.Component {
constructor(props) {
@@ -23,19 +76,20 @@ export default class ErrorBoundary extends React.Component {
}
render() {
if (this.state.hasError) {
let message;
try {
message = (this.state.error.message || this.state.error).toString();
} catch (e) {
message = 'Error detected';
}
// You can render any custom fallback UI
return (
<div>
<ErrorInfo message={message} />;
</div>
);
return <ErrorScreen error={this.state.error} />;
}
return this.props.children;
}
}
export function ErrorBoundaryTest({ children }) {
let error;
try {
const x = 1;
// @ts-ignore
x.log();
} catch (err) {
error = err;
}
return <ErrorScreen error={error} />;
}
@@ -43,6 +43,8 @@ export default function useEditorData({ tabid, reloadToken = 0, loadFromArgs = n
setValue(init);
valueRef.current = init;
initialDataRef.current = init;
// mark as not saved
changeCounterRef.current += 1;
} catch (err) {
const message = (err && err.response && err.response.data && err.response.data.error) || 'Loading failed';
setErrorMessage(message);
@@ -0,0 +1,31 @@
import useNewQuery from '../query/useNewQuery';
import getElectron from './getElectron';
export default function useOpenElectronFile() {
const electron = getElectron();
const newQuery = useNewQuery();
return () => {
const filePaths = electron.remote.dialog.showOpenDialogSync(electron.remote.getCurrentWindow(), {
filters: { name: `SQL files`, extensions: ['sql'] },
});
const filePath = filePaths && filePaths[0];
if (filePath) {
if (filePath.match(/.sql$/i)) {
const path = window.require('path');
const fs = window.require('fs');
const parsed = path.parse(filePath);
const data = fs.readFileSync(filePath, { encoding: 'utf-8' });
newQuery({
title: parsed.name,
initialData: data,
// @ts-ignore
savedFilePath: filePath,
savedFormat: 'text',
});
}
}
};
}
+26 -4
View File
@@ -7,6 +7,14 @@ import { useOpenedTabs, useSetOpenedTabs } from './globalState';
import tabs from '../tabs';
import { setSelectedTabFunc } from './common';
function findFreeNumber(numbers) {
if (numbers.length == 0) return 1;
return _.max(numbers) + 1;
// let res = 1;
// while (numbers.includes(res)) res += 1;
// return res;
}
export default function useOpenNewTab() {
const setOpenedTabs = useSetOpenedTabs();
const openedTabs = useOpenedTabs();
@@ -15,11 +23,16 @@ export default function useOpenNewTab() {
async (newTab, initialData = undefined, options) => {
let existing = null;
const { savedFile } = newTab.props || {};
if (savedFile) {
const { savedFile, savedFolder, savedFilePath } = newTab.props || {};
if (savedFile || savedFilePath) {
existing = openedTabs.find(
x =>
x.props && x.tabComponent == newTab.tabComponent && x.closedTime == null && x.props.savedFile == savedFile
x.props &&
x.tabComponent == newTab.tabComponent &&
x.closedTime == null &&
x.props.savedFile == savedFile &&
x.props.savedFolder == savedFolder &&
x.props.savedFilePath == savedFilePath
);
}
@@ -42,6 +55,15 @@ export default function useOpenNewTab() {
return;
}
// new tab will be created
if (newTab.title.endsWith('#')) {
const numbers = openedTabs
.filter(x => x.closedTime == null && x.title && x.title.startsWith(newTab.title))
.map(x => parseInt(x.title.substring(newTab.title.length)));
newTab.title = `${newTab.title}${findFreeNumber(numbers)}`;
}
const tabid = uuidv1();
if (initialData) {
for (const key of _.keys(initialData)) {
@@ -61,7 +83,7 @@ export default function useOpenNewTab() {
},
]);
},
[setOpenedTabs]
[setOpenedTabs, openedTabs]
);
return openNewTab;
+7 -3
View File
@@ -16,12 +16,16 @@ export default function useStorage(key, storageObject, initialValue) {
}
});
// use storedValue to ref, so that setValue with function argument works without changeing setValue itself
const storedValueRef = React.useRef(storedValue);
storedValueRef.current = storedValue;
// Return a wrapped version of useState's setter function that ...
// ... persists the new value to localStorage.
const setValue = value => {
const setValue = React.useCallback(value => {
try {
// Allow value to be a function so we have same API as useState
const valueToStore = value instanceof Function ? value(storedValue) : value;
const valueToStore = value instanceof Function ? value(storedValueRef.current) : value;
// Save state
setStoredValue(valueToStore);
// Save to local storage
@@ -31,7 +35,7 @@ export default function useStorage(key, storageObject, initialValue) {
console.error(error);
console.log('Error saving storage value', key, value);
}
};
}, []);
return [storedValue, setValue];
}
+37
View File
@@ -0,0 +1,37 @@
import React from 'react';
import _ from 'lodash';
function formatSeconds(duration) {
if (duration == null) return '';
const hours = _.padStart(Math.floor(duration / 3600).toString(), 2, '0');
const minutes = _.padStart((Math.floor(duration / 60) % 60).toString(), 2, '0');
const seconds = _.padStart((duration % 60).toString(), 2, '0');
return `${hours}:${minutes}:${seconds}`;
}
export default function useTimerLabel() {
const [duration, setDuration] = React.useState(null);
const [busy, setBusy] = React.useState(false);
React.useEffect(() => {
if (busy) {
setDuration(0);
const handle = setInterval(() => setDuration(x => x + 1), 1000);
return () => window.clearInterval(handle);
}
}, [busy]);
const start = React.useCallback(() => {
setBusy(true);
}, []);
const stop = React.useCallback(() => {
setBusy(false);
}, []);
return {
start,
stop,
text: formatSeconds(duration),
duration,
};
}
+4 -4
View File
@@ -44,14 +44,14 @@ export default function FavoritesWidget() {
const hasPermission = useHasPermission();
return (
<WidgetColumnBar>
<WidgetColumnBarItem title="Recently closed tabs" name="closedTabs" height="20%">
<ClosedTabsList />
</WidgetColumnBarItem>
{hasPermission('files/favorites/read') && (
<WidgetColumnBarItem title="Favorites" name="favorites" height="15%">
<WidgetColumnBarItem title="Favorites" name="favorites" height="20%">
<FavoritesList />
</WidgetColumnBarItem>
)}
<WidgetColumnBarItem title="Recently closed tabs" name="closedTabs">
<ClosedTabsList />
</WidgetColumnBarItem>
</WidgetColumnBar>
);
}
+55 -49
View File
@@ -10,12 +10,11 @@ const Container = styled.div`
display: flex;
color: ${props => props.theme.statusbar_font1};
align-items: stretch;
justify-content: space-between;
`;
const Item = styled.div`
export const StatusBarItem = styled.div`
padding: 2px 10px;
// margin: auto;
// flex-grow: 0;
`;
const ErrorWrapper = styled.span`
@@ -30,62 +29,69 @@ const InfoWrapper = styled.span`
props.theme.statusbar_font_green[5]};
`;
export default function StatusBar() {
const StatusbarContainer = styled.div`
display: flex;
`;
export default function StatusBar({ statusbarPortalRef }) {
const { name, connection } = useCurrentDatabase() || {};
const status = useDatabaseStatus(connection ? { conid: connection._id, database: name } : {});
const { displayName, server, user, engine } = connection || {};
const theme = useTheme();
return (
<Container theme={theme}>
{name && (
<Item>
<FontIcon icon="icon database" /> {name}
</Item>
)}
{(displayName || server) && (
<Item>
<FontIcon icon="icon server" /> {displayName || server}
</Item>
)}
<StatusbarContainer>
{name && (
<StatusBarItem>
<FontIcon icon="icon database" /> {name}
</StatusBarItem>
)}
{(displayName || server) && (
<StatusBarItem>
<FontIcon icon="icon server" /> {displayName || server}
</StatusBarItem>
)}
{user && (
<Item>
<FontIcon icon="icon account" /> {user}
</Item>
)}
{user && (
<StatusBarItem>
<FontIcon icon="icon account" /> {user}
</StatusBarItem>
)}
{connection && status && (
<Item>
{status.name == 'pending' && (
{connection && status && (
<StatusBarItem>
{status.name == 'pending' && (
<>
<FontIcon icon="icon loading" /> Loading
</>
)}
{status.name == 'ok' && (
<>
<InfoWrapper theme={theme}>
<FontIcon icon="icon ok" />
</InfoWrapper>{' '}
Connected
</>
)}
{status.name == 'error' && (
<>
<ErrorWrapper theme={theme}>
<FontIcon icon="icon error" />
</ErrorWrapper>{' '}
Error
</>
)}
</StatusBarItem>
)}
{!connection && (
<StatusBarItem>
<>
<FontIcon icon="icon loading" /> Loading
<FontIcon icon="icon disconnected" /> Not connected
</>
)}
{status.name == 'ok' && (
<>
<InfoWrapper theme={theme}>
<FontIcon icon="icon ok" />
</InfoWrapper>{' '}
Connected
</>
)}
{status.name == 'error' && (
<>
<ErrorWrapper theme={theme}>
<FontIcon icon="icon error" />
</ErrorWrapper>{' '}
Error
</>
)}
</Item>
)}
{!connection && (
<Item>
<>
<FontIcon icon="icon disconnected" /> Not connected
</>
</Item>
)}
</StatusBarItem>
)}
</StatusbarContainer>
<StatusbarContainer ref={statusbarPortalRef}></StatusbarContainer>
</Container>
);
}
+5 -2
View File
@@ -25,6 +25,7 @@ import tabs from '../tabs';
import FavoriteModal from '../modals/FavoriteModal';
import { useOpenFavorite } from '../appobj/FavoriteFileAppObject';
import ErrorMessageModal from '../modals/ErrorMessageModal';
import useOpenElectronFile from '../utility/useOpenElectronFile';
const ToolbarContainer = styled.div`
display: flex;
@@ -48,6 +49,7 @@ export default function ToolBar({ toolbarPortalRef }) {
const electron = getElectron();
const favorites = useFavorites();
const openFavorite = useOpenFavorite();
const openElectronFile = useOpenElectronFile();
const currentTab = openedTabs.find(x => x.selected);
@@ -58,6 +60,7 @@ export default function ToolBar({ toolbarPortalRef }) {
window['dbgate_newQuery'] = newQuery;
window['dbgate_closeAll'] = () => setOpenedTabs([]);
window['dbgate_showAbout'] = showAbout;
window['dbgate_openFile'] = openElectronFile;
});
const showAbout = () => {
@@ -91,7 +94,7 @@ export default function ToolBar({ toolbarPortalRef }) {
const newMarkdown = () => {
openNewTab({
title: 'Page',
title: 'Page #',
tabComponent: 'MarkdownEditorTab',
icon: 'img markdown',
});
@@ -103,7 +106,7 @@ export default function ToolBar({ toolbarPortalRef }) {
const newShell = () => {
openNewTab({
title: 'Shell',
title: 'Shell #',
icon: 'img shell',
tabComponent: 'ShellTab',
});