@adecore/database 0.17.0-beta.2 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/DatabaseProvider.d.ts +4 -3
- package/dist/DatabaseProvider.js +15 -3
- package/dist/KeyIcon.d.ts +4 -0
- package/dist/KeyIcon.js +14 -0
- package/dist/actions.d.ts +12 -0
- package/dist/client-context.d.ts +3 -1
- package/dist/client-context.js +2 -0
- package/dist/code-text.d.ts +1 -0
- package/dist/code-text.js +2 -0
- package/dist/column-keys.d.ts +5 -0
- package/dist/column-keys.js +2 -0
- package/dist/connections/ConnectionManager.d.ts +3 -2
- package/dist/connections/ConnectionManager.js +27 -16
- package/dist/connections/connection-config.js +2 -0
- package/dist/console/QueryConsole.d.ts +8 -3
- package/dist/console/QueryConsole.js +110 -50
- package/dist/console/SqlEditor.d.ts +2 -12
- package/dist/console/SqlEditor.js +29 -18
- package/dist/console/StatementResultView.d.ts +5 -1
- package/dist/console/StatementResultView.js +18 -5
- package/dist/console/TransactionControls.js +3 -6
- package/dist/console/editor-slot.d.ts +18 -0
- package/dist/console/editor-slot.js +1 -0
- package/dist/console/result-keys.d.ts +15 -0
- package/dist/console/result-keys.js +79 -0
- package/dist/designer/ColumnsEditor.js +2 -2
- package/dist/designer/DesignerView.d.ts +3 -2
- package/dist/designer/DesignerView.js +5 -4
- package/dist/designer/SqlPreview.js +2 -1
- package/dist/designer/TableDesigner.d.ts +3 -2
- package/dist/designer/TableDesigner.js +2 -2
- package/dist/explorer/DatabaseExplorer.d.ts +4 -1
- package/dist/explorer/DatabaseExplorer.js +79 -23
- package/dist/explorer/RowMenu.js +2 -2
- package/dist/explorer/expansion.js +2 -2
- package/dist/explorer/folders.d.ts +16 -0
- package/dist/explorer/folders.js +1 -0
- package/dist/explorer/tree.d.ts +55 -4
- package/dist/explorer/tree.js +137 -23
- package/dist/grid/CellEditor.js +3 -1
- package/dist/grid/DataGrid.d.ts +2 -2
- package/dist/grid/DataGrid.js +61 -59
- package/dist/grid/EnumPicker.js +2 -1
- package/dist/grid/GridHeaderCell.d.ts +1 -2
- package/dist/grid/GridHeaderCell.js +12 -5
- package/dist/grid/GridRowView.d.ts +49 -0
- package/dist/grid/GridRowView.js +51 -0
- package/dist/grid/RecordView.d.ts +4 -2
- package/dist/grid/RecordView.js +71 -22
- package/dist/grid/ValueDock.d.ts +3 -1
- package/dist/grid/ValueDock.js +9 -3
- package/dist/grid/cell-id.d.ts +1 -0
- package/dist/grid/cell-id.js +2 -0
- package/dist/grid/layout.d.ts +3 -2
- package/dist/grid/layout.js +15 -6
- package/dist/grid/types.d.ts +8 -1
- package/dist/grid/types.js +4 -3
- package/dist/index.d.ts +5 -1
- package/dist/index.js +2 -0
- package/dist/locales/en.json +32 -15
- package/dist/locales/nl.json +32 -15
- package/dist/protocol/index.d.ts +1 -1
- package/dist/protocol/messages.d.ts +1 -1
- package/dist/protocol/messages.js +1 -1
- package/dist/protocol/schema.d.ts +17 -0
- package/dist/structure/StructureView.d.ts +5 -3
- package/dist/structure/StructureView.js +8 -6
- package/dist/table/CommandField.js +2 -2
- package/dist/table/TableStatusBar.js +2 -2
- package/dist/table/TableToolbar.d.ts +3 -4
- package/dist/table/TableToolbar.js +4 -3
- package/dist/table/TableView.d.ts +4 -2
- package/dist/table/TableView.js +63 -91
- package/dist/table/grid-rows.js +4 -1
- package/dist/table/useTableTransfer.d.ts +3 -1
- package/dist/table/useTableTransfer.js +19 -5
- package/dist/testing/fake.d.ts +3 -1
- package/dist/testing/fake.js +7 -1
- package/dist/value/ValueEditor.d.ts +9 -0
- package/dist/value/ValueEditor.js +102 -0
- package/dist/value/ValuePanel.d.ts +1 -1
- package/dist/value/ValuePanel.js +3 -94
- package/dist/workbench/DatabaseWorkbench.js +4 -7
- package/package.json +6 -6
- package/src/DatabaseProvider.tsx +24 -5
- package/src/KeyIcon.tsx +15 -0
- package/src/actions.ts +28 -2
- package/src/client-context.ts +5 -1
- package/src/code-text.ts +2 -0
- package/src/column-keys.ts +8 -0
- package/src/connections/ConnectionManager.tsx +98 -68
- package/src/connections/connection-config.ts +2 -0
- package/src/console/QueryConsole.tsx +251 -150
- package/src/console/SqlEditor.tsx +33 -37
- package/src/console/StatementResultView.tsx +61 -27
- package/src/console/TransactionControls.tsx +8 -11
- package/src/console/editor-slot.ts +27 -0
- package/src/console/result-keys.ts +109 -0
- package/src/designer/ColumnsEditor.tsx +3 -3
- package/src/designer/DesignerView.tsx +14 -3
- package/src/designer/SqlPreview.tsx +2 -1
- package/src/designer/TableDesigner.tsx +5 -2
- package/src/explorer/DatabaseExplorer.tsx +170 -26
- package/src/explorer/RowMenu.tsx +2 -2
- package/src/explorer/expansion.ts +4 -4
- package/src/explorer/folders.ts +25 -0
- package/src/explorer/tree.ts +219 -28
- package/src/grid/CellEditor.tsx +3 -2
- package/src/grid/DataGrid.tsx +114 -176
- package/src/grid/EnumPicker.tsx +2 -1
- package/src/grid/GridHeaderCell.tsx +14 -9
- package/src/grid/GridRowView.tsx +184 -0
- package/src/grid/RecordView.tsx +188 -75
- package/src/grid/ValueDock.tsx +12 -3
- package/src/grid/cell-id.ts +2 -0
- package/src/grid/layout.ts +16 -6
- package/src/grid/types.ts +15 -3
- package/src/index.ts +5 -1
- package/src/locales/en.json +32 -15
- package/src/locales/nl.json +32 -15
- package/src/protocol/index.ts +13 -1
- package/src/protocol/messages.ts +1 -1
- package/src/protocol/schema.ts +28 -0
- package/src/structure/StructureView.tsx +16 -6
- package/src/table/CommandField.tsx +2 -2
- package/src/table/TableStatusBar.tsx +3 -3
- package/src/table/TableToolbar.tsx +107 -92
- package/src/table/TableView.tsx +95 -132
- package/src/table/grid-rows.ts +5 -3
- package/src/table/useTableTransfer.ts +21 -6
- package/src/testing/fake.ts +18 -1
- package/src/value/ValueEditor.tsx +213 -0
- package/src/value/ValuePanel.tsx +4 -206
- package/src/workbench/DatabaseWorkbench.tsx +9 -10
package/dist/value/ValuePanel.js
CHANGED
|
@@ -1,103 +1,12 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useMemo, useState } from 'react';
|
|
3
2
|
import clsx from 'clsx';
|
|
4
|
-
import {
|
|
3
|
+
import { MousePointerClick, X } from 'lucide-react';
|
|
5
4
|
import { useTranslation } from 'react-i18next';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { editValueOf, sameDraft, sameSource, sourceOf } from './draft.js';
|
|
9
|
-
import { hexDumpLines, parseHex } from './hex.js';
|
|
10
|
-
import { renderView, sizeOf, viewsOf } from './views.js';
|
|
11
|
-
const WRAPPING_VIEWS = new Set(['text', 'formatted', 'utf8']);
|
|
12
|
-
const READING = 'min-h-0 flex-1 overflow-auto rounded-lg border border-border bg-surface p-2 font-mono text-code text-text';
|
|
5
|
+
import { EmptyState, IconButton, PanelHeader } from '@adecore/ui';
|
|
6
|
+
import { ValueEditor } from './ValueEditor.js';
|
|
13
7
|
/* The whole value of the focused cell beside the grid, to read and edit what a cell is too small for. */
|
|
14
8
|
export function ValuePanel({ column, value, loading, editable, onCommit, onClose, className, ref }) {
|
|
15
9
|
const { t } = useTranslation('database');
|
|
16
10
|
const ready = column !== null && !loading && value !== undefined;
|
|
17
11
|
return (_jsxs("div", { ref: ref, className: clsx('flex h-full min-h-0 min-w-0 flex-col', className), children: [_jsxs(PanelHeader, { title: column === null ? t('value.title') : undefined, children: [column !== null && (_jsxs(_Fragment, { children: [_jsx("span", { className: "min-w-0 truncate text-sm font-medium text-text", children: column.name }), column.type !== '' && _jsx("span", { className: "min-w-0 shrink-0 truncate text-xs text-text-faint", children: column.type })] })), _jsx(IconButton, { icon: X, size: "sm", label: t('value.close'), className: "ml-auto", onClick: onClose })] }), ready ? (_jsx(ValueEditor, { column: column, value: value, editable: editable, onCommit: onCommit })) : (_jsx("div", { className: "grid min-h-0 grow place-items-center", children: _jsx(EmptyState, { icon: column === null ? MousePointerClick : undefined, busy: column !== null && loading, children: column === null ? t('value.selectCell') : loading ? t('value.loading') : t('value.notLoaded') }) }))] }));
|
|
18
12
|
}
|
|
19
|
-
function ValueEditor({ column, value, editable, onCommit }) {
|
|
20
|
-
const { t } = useTranslation('database');
|
|
21
|
-
const { name, type, kind } = column;
|
|
22
|
-
const source = useMemo(() => sourceOf({ name, type, kind }, value), [name, type, kind, value]);
|
|
23
|
-
const [seen, setSeen] = useState(source);
|
|
24
|
-
const [draft, setDraft] = useState(source.original);
|
|
25
|
-
const [choice, setChoice] = useState(null);
|
|
26
|
-
const [problem, setProblem] = useState(null);
|
|
27
|
-
const [wrap, setWrap] = useState(true);
|
|
28
|
-
// A new cell, or the same cell with a new value, starts over from that value.
|
|
29
|
-
if (!sameSource(seen, source)) {
|
|
30
|
-
setSeen(source);
|
|
31
|
-
setDraft(source.original);
|
|
32
|
-
setChoice(null);
|
|
33
|
-
setProblem(null);
|
|
34
|
-
}
|
|
35
|
-
const views = viewsOf(source, draft);
|
|
36
|
-
const view = choice !== null && views.includes(choice) ? choice : views[0];
|
|
37
|
-
const dirty = !sameDraft(draft, source.original);
|
|
38
|
-
const rendered = useMemo(() => (view === 'text' ? null : renderView(view, draft)), [view, draft]);
|
|
39
|
-
const hex = source.binary && draft.mode === 'value' ? parseHex(draft.text) : null;
|
|
40
|
-
const shownProblem = problem ?? (hex !== null && !hex.ok ? { kind: 'hex', problem: hex.problem } : null);
|
|
41
|
-
const size = sizeOf(source, draft);
|
|
42
|
-
const placeholder = draft.mode === 'null' ? 'NULL' : draft.mode === 'default' ? 'DEFAULT' : undefined;
|
|
43
|
-
const change = (next) => {
|
|
44
|
-
setDraft(next);
|
|
45
|
-
setProblem(null);
|
|
46
|
-
};
|
|
47
|
-
const apply = () => {
|
|
48
|
-
if (!editable || onCommit === undefined || !dirty) {
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
const conversion = editValueOf(draft, source);
|
|
52
|
-
if (conversion.ok) {
|
|
53
|
-
onCommit(conversion.value);
|
|
54
|
-
}
|
|
55
|
-
else {
|
|
56
|
-
setProblem(conversion.problem);
|
|
57
|
-
}
|
|
58
|
-
};
|
|
59
|
-
const handleKeyDown = (event) => {
|
|
60
|
-
if (!event.nativeEvent.isComposing && matchesShortcut(KEY_SHORTCUTS.modEnter, event.nativeEvent, isApplePlatform())) {
|
|
61
|
-
event.preventDefault();
|
|
62
|
-
apply();
|
|
63
|
-
}
|
|
64
|
-
};
|
|
65
|
-
const copy = () => {
|
|
66
|
-
if (view === 'hex' && hex !== null && hex.ok) {
|
|
67
|
-
copyText(hexDumpLines(hex.hex).join('\n'));
|
|
68
|
-
}
|
|
69
|
-
else if (rendered !== null && rendered.kind === 'text') {
|
|
70
|
-
copyText(rendered.text);
|
|
71
|
-
}
|
|
72
|
-
else {
|
|
73
|
-
copyText(draft.mode === 'value' ? draft.text : draft.mode === 'null' ? 'NULL' : 'DEFAULT');
|
|
74
|
-
}
|
|
75
|
-
};
|
|
76
|
-
const editor = (extra) => (_jsx(TextArea, { mono: true, "aria-label": t(source.binary ? 'value.hexEditor' : 'value.editor', { column: column.name }), wrap: wrap || source.binary ? 'soft' : 'off', spellCheck: false, readOnly: !editable, placeholder: placeholder, value: draft.mode === 'value' ? draft.text : '', className: extra, onChange: (event) => change({ text: event.target.value, mode: 'value' }), onKeyDown: handleKeyDown }));
|
|
77
|
-
const problemText = (found) => found.kind === 'json'
|
|
78
|
-
? t('value.invalidJson', { reason: found.reason })
|
|
79
|
-
: t(found.problem === 'odd' ? 'value.invalidHexOdd' : 'value.invalidHexCharacters');
|
|
80
|
-
const reading = (content, wrapped) => {
|
|
81
|
-
if (content.kind === 'empty') {
|
|
82
|
-
return _jsx(Faint, { children: placeholder });
|
|
83
|
-
}
|
|
84
|
-
if (content.kind === 'problem') {
|
|
85
|
-
return _jsx(Faint, { children: problemText(content.problem) });
|
|
86
|
-
}
|
|
87
|
-
return (_jsxs(_Fragment, { children: [_jsx("pre", { className: clsx(READING, wrapped ? 'break-words whitespace-pre-wrap' : 'whitespace-pre'), children: content.text }), content.truncated && (_jsx("p", { className: "shrink-0 text-xs text-text-faint", children: t('value.dumpTruncated', { shown: formatNumber(content.truncated.shown), total: formatNumber(content.truncated.total) }) }))] }));
|
|
88
|
-
};
|
|
89
|
-
const body = () => {
|
|
90
|
-
if (view === 'text') {
|
|
91
|
-
return editor('min-h-0 flex-1');
|
|
92
|
-
}
|
|
93
|
-
if (view === 'hex' && editable) {
|
|
94
|
-
return (_jsxs(_Fragment, { children: [draft.mode === 'value' && rendered !== null && reading(rendered, false), editor(draft.mode === 'value' ? 'flex-none' : 'min-h-0 flex-1')] }));
|
|
95
|
-
}
|
|
96
|
-
return rendered === null ? null : reading(rendered, wrap && WRAPPING_VIEWS.has(view));
|
|
97
|
-
};
|
|
98
|
-
return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex h-10 shrink-0 items-center gap-2 px-2", children: [views.length > 1 && (_jsx(Segmented, { value: view, onValueChange: (id) => setChoice(id), label: t('value.views'), options: views.map((id) => ({ id, label: t(`value.view.${id}`) })) })), _jsxs("div", { className: "ml-auto flex items-center gap-1", children: [WRAPPING_VIEWS.has(view) && (_jsx(IconButton, { icon: WrapText, size: "sm", label: t('value.wrap'), "aria-pressed": wrap, active: wrap, onClick: () => setWrap(!wrap) })), _jsx(IconButton, { icon: Copy, size: "sm", label: t('value.copy'), onClick: copy })] })] }), _jsx("div", { className: "flex min-h-0 flex-1 flex-col gap-2 px-2 pb-2", children: body() }), shownProblem !== null && _jsx(FormError, { className: "shrink-0 px-3 pb-2 text-xs", children: problemText(shownProblem) }), editable && (_jsxs("div", { className: "flex shrink-0 items-center gap-1.5 border-t border-border p-2", children: [_jsxs(Menu.Root, { children: [_jsx(IconButton, { icon: Ellipsis, size: "sm", label: t('value.more'), render: _jsx(Menu.Trigger, {}) }), _jsxs(Menu.Popup, { children: [column.nullable !== false && (_jsx(Menu.Item, { disabled: draft.mode === 'null', onClick: () => change({ text: '', mode: 'null' }), children: t('value.setNull') })), _jsx(Menu.Item, { disabled: draft.mode === 'default', onClick: () => change({ text: '', mode: 'default' }), children: t('value.setDefault') })] })] }), _jsxs("span", { className: "ml-auto flex items-center gap-1.5", children: [_jsx(Button, { size: "xs", disabled: !dirty, onClick: () => change(source.original), children: t('value.revert') }), _jsx(Tooltip, { label: t('value.applyHint'), kbd: KEY_SHORTCUTS.modEnter, children: _jsx(Button, { size: "xs", variant: "primary", disabled: !dirty || onCommit === undefined, onClick: apply, children: t('value.apply') }) })] })] })), _jsx("footer", { className: "flex h-8 shrink-0 items-center border-t border-border px-3 text-xs text-text-muted tabular-nums", children: size !== null &&
|
|
99
|
-
(size.unit === 'bytes' ? formatBytes(size.count) : t('value.characters', { count: size.count, formatted: formatNumber(size.count) })) })] }));
|
|
100
|
-
}
|
|
101
|
-
function Faint({ children }) {
|
|
102
|
-
return _jsx("div", { className: "grid min-h-0 flex-1 place-items-center font-mono text-code text-text-faint", children: children });
|
|
103
|
-
}
|
|
@@ -3,10 +3,10 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|
|
3
3
|
import clsx from 'clsx';
|
|
4
4
|
import { PencilRuler, Settings2, SquareTerminal, Table, TableProperties } from 'lucide-react';
|
|
5
5
|
import { useTranslation } from 'react-i18next';
|
|
6
|
-
import { Button, ColumnResizeHandle, Dialog, EmptyState, Icon, IconButton, PromptDialog, Segmented, Tabs, isApplePlatform, matchesShortcut, shortcut, useColumnResize } from '@adecore/ui';
|
|
6
|
+
import { Button, CloseButton, ColumnResizeHandle, Dialog, EmptyState, Icon, IconButton, PromptDialog, Segmented, Tabs, isApplePlatform, matchesShortcut, shortcut, useColumnResize } from '@adecore/ui';
|
|
7
7
|
import { formatNumber } from '@adecore/ui/format';
|
|
8
8
|
import { DatabaseProvider } from '../DatabaseProvider.js';
|
|
9
|
-
import { useDatabaseAction,
|
|
9
|
+
import { useDatabaseAction, useDatabaseStorage } from '../client-context.js';
|
|
10
10
|
import { ConnectionManager } from '../connections/ConnectionManager.js';
|
|
11
11
|
import { QueryConsole } from '../console/QueryConsole.js';
|
|
12
12
|
import { DatabaseExplorer } from '../explorer/DatabaseExplorer.js';
|
|
@@ -23,11 +23,8 @@ const MIN_WIDTH = 200;
|
|
|
23
23
|
*/
|
|
24
24
|
export function DatabaseWorkbench({ connections, onConnectionsChange, className, ref }) {
|
|
25
25
|
const { t } = useTranslation('database');
|
|
26
|
-
const client = useDatabaseClient();
|
|
27
26
|
const outer = useDatabaseAction();
|
|
28
27
|
const storage = useDatabaseStorage();
|
|
29
|
-
const files = useDatabaseFiles();
|
|
30
|
-
const numberNotation = useNumberNotation();
|
|
31
28
|
const column = useRef(null);
|
|
32
29
|
const [state, setState] = useState(() => parseState(storage?.get(STORAGE_KEY) ?? null));
|
|
33
30
|
const [selection, setSelection] = useState(null);
|
|
@@ -160,7 +157,7 @@ export function DatabaseWorkbench({ connections, onConnectionsChange, className,
|
|
|
160
157
|
const canOpenConsole = consoleContext() !== null;
|
|
161
158
|
const closingTab = state.tabs.find((tab) => tab.id === closing);
|
|
162
159
|
const closingTable = closingTab?.kind === 'table' ? closingTab.ref.table : '';
|
|
163
|
-
return (_jsx(DatabaseProvider, {
|
|
160
|
+
return (_jsx(DatabaseProvider, { onAction: onAction, children: _jsxs("div", { ref: ref, className: clsx('flex h-full min-h-0 min-w-0 bg-surface text-text', className), onKeyDown: onKeyDown, children: [_jsxs("aside", { ref: column, className: "relative flex shrink-0 flex-col border-r border-border", style: { width }, children: [_jsxs("header", { className: "flex h-9 shrink-0 items-center justify-between border-b border-border pr-2 pl-3", children: [_jsx("h2", { className: "text-sm font-semibold text-text", children: t('workbench.title') }), canManage && (_jsx(IconButton, { icon: Settings2, size: "sm", label: t('workbench.manage'), onClick: () => {
|
|
164
161
|
setManaged(null);
|
|
165
162
|
setManaging(true);
|
|
166
163
|
} }))] }), _jsx(DatabaseExplorer, { connections: connections, value: selection, onValueChange: setSelection, className: "min-h-0 flex-1" }), _jsx(ColumnResizeHandle, { from: "left", onPointerDown: startResize })] }), _jsxs(Tabs.Root, { value: active?.id ?? null, onValueChange: (id) => setState((current) => focusTab(current, String(id))), className: "flex min-h-0 min-w-0 flex-1 flex-col", children: [_jsx(Tabs.List, { "aria-label": t('workbench.tabs'), className: "px-2", end: _jsxs(_Fragment, { children: [active?.kind === 'table' && (_jsx(Segmented, { label: t('workbench.views'), value: active.view, onValueChange: (view) => setState((current) => setTableView(current, active.id, view)), options: [
|
|
@@ -175,5 +172,5 @@ export function DatabaseWorkbench({ connections, onConnectionsChange, className,
|
|
|
175
172
|
if (!open) {
|
|
176
173
|
setClosing(null);
|
|
177
174
|
}
|
|
178
|
-
} }), onConnectionsChange !== undefined && (_jsx(Dialog.Root, { open: managing, onOpenChange: setManaging, children: _jsxs(Dialog.Popup, { className: "flex h-[
|
|
175
|
+
} }), onConnectionsChange !== undefined && (_jsx(Dialog.Root, { open: managing, onOpenChange: setManaging, children: _jsxs(Dialog.Popup, { className: "flex h-[640px] w-[800px] flex-col overflow-hidden", children: [_jsxs("div", { className: "flex items-center gap-4 border-b border-border px-5 py-4", children: [_jsx(Dialog.Title, { className: "grow", children: t('workbench.connections') }), _jsx(CloseButton, { label: t('workbench.close'), dialog: true })] }), _jsx(ConnectionManager, { value: connections, onValueChange: onConnectionsChange, selected: managed, onSelectedChange: setManaged, className: "min-h-0 flex-1" }), _jsx("div", { className: "flex shrink-0 justify-end border-t border-border px-5 py-3", children: _jsx(Dialog.Close, { render: _jsx(Button, { variant: "secondary" }), children: t('workbench.close') }) })] }) }))] }) }));
|
|
179
176
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adecore/database",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Browse, query and edit SQLite and MySQL databases: React views for the page, a host for the backend and a native helper that talks to the server.",
|
|
5
5
|
"license": "FSL-1.1-MIT",
|
|
6
6
|
"author": "Bas Milius",
|
|
@@ -61,13 +61,13 @@
|
|
|
61
61
|
"lucide-react": "^1.47.0"
|
|
62
62
|
},
|
|
63
63
|
"optionalDependencies": {
|
|
64
|
-
"@adecore/database-darwin-arm64": "0.
|
|
65
|
-
"@adecore/database-linux-arm64": "0.
|
|
66
|
-
"@adecore/database-linux-x64": "0.
|
|
67
|
-
"@adecore/database-win32-x64": "0.
|
|
64
|
+
"@adecore/database-darwin-arm64": "0.18.0",
|
|
65
|
+
"@adecore/database-linux-arm64": "0.18.0",
|
|
66
|
+
"@adecore/database-linux-x64": "0.18.0",
|
|
67
|
+
"@adecore/database-win32-x64": "0.18.0"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
70
|
-
"@adecore/ui": "^0.
|
|
70
|
+
"@adecore/ui": "^0.18.0",
|
|
71
71
|
"i18next": "^26.4.2",
|
|
72
72
|
"react": "^19.3.0",
|
|
73
73
|
"react-dom": "^19.3.0",
|
package/src/DatabaseProvider.tsx
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
|
-
import { useMemo, useState, type ReactNode } from 'react';
|
|
1
|
+
import { use, useMemo, useState, type ReactNode } from 'react';
|
|
2
2
|
import { useTranslation } from 'react-i18next';
|
|
3
|
-
import type { DatabaseAction, DatabaseFiles, DatabaseStorage } from './actions.ts';
|
|
3
|
+
import type { DatabaseAction, DatabaseFiles, DatabaseNotice, DatabaseStorage } from './actions.ts';
|
|
4
4
|
import type { DatabaseClient } from './client/types.ts';
|
|
5
5
|
import { DatabaseContext } from './client-context.ts';
|
|
6
6
|
import type { NumberNotation } from './grid/display.ts';
|
|
7
7
|
import { addDatabaseResources } from './locales.ts';
|
|
8
8
|
|
|
9
9
|
export interface DatabaseProviderProps {
|
|
10
|
-
|
|
10
|
+
/* Required on the outermost provider. A provider inside another takes every prop it leaves out from the one above. */
|
|
11
|
+
client?: DatabaseClient;
|
|
11
12
|
/* Where a table, a console or the designer a view asks for opens. Without it the views offer none of those. */
|
|
12
13
|
onAction?(action: DatabaseAction): void;
|
|
14
|
+
/* Where a view's passing news goes, such as an export that finished. Without it each view shows its own above its content. */
|
|
15
|
+
onNotice?(notice: DatabaseNotice): void;
|
|
13
16
|
storage?: DatabaseStorage;
|
|
14
17
|
files?: DatabaseFiles;
|
|
15
18
|
/*
|
|
@@ -22,8 +25,9 @@ export interface DatabaseProviderProps {
|
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
/* Hands every view below it the client and the app's hooks, and adds the package's words to the i18next the app's `UIProvider` was given. Mount it inside that provider. */
|
|
25
|
-
export function DatabaseProvider({ client, onAction, storage, files, numberNotation
|
|
28
|
+
export function DatabaseProvider({ client, onAction, onNotice, storage, files, numberNotation, children }: DatabaseProviderProps) {
|
|
26
29
|
const { i18n } = useTranslation();
|
|
30
|
+
const outer = use(DatabaseContext);
|
|
27
31
|
const [added, setAdded] = useState<typeof i18n | null>(null);
|
|
28
32
|
// In render, as `UIProvider` does, so the first view already reads its words.
|
|
29
33
|
if (added !== i18n) {
|
|
@@ -31,7 +35,22 @@ export function DatabaseProvider({ client, onAction, storage, files, numberNotat
|
|
|
31
35
|
setAdded(i18n);
|
|
32
36
|
}
|
|
33
37
|
|
|
34
|
-
const
|
|
38
|
+
const ownClient = client ?? outer?.client;
|
|
39
|
+
const ownAction = onAction ?? outer?.onAction;
|
|
40
|
+
const ownNotice = onNotice ?? outer?.onNotice;
|
|
41
|
+
const ownStorage = storage ?? outer?.storage;
|
|
42
|
+
const ownFiles = files ?? outer?.files;
|
|
43
|
+
const ownNotation = numberNotation ?? outer?.numberNotation ?? 'database';
|
|
44
|
+
const value = useMemo(
|
|
45
|
+
() =>
|
|
46
|
+
ownClient === undefined
|
|
47
|
+
? null
|
|
48
|
+
: { client: ownClient, onAction: ownAction, onNotice: ownNotice, storage: ownStorage, files: ownFiles, numberNotation: ownNotation },
|
|
49
|
+
[ownClient, ownAction, ownNotice, ownStorage, ownFiles, ownNotation]
|
|
50
|
+
);
|
|
51
|
+
if (value === null) {
|
|
52
|
+
throw new Error('A DatabaseProvider needs a client, or a DatabaseProvider above it to take one from.');
|
|
53
|
+
}
|
|
35
54
|
|
|
36
55
|
return <DatabaseContext value={value}>{children}</DatabaseContext>;
|
|
37
56
|
}
|
package/src/KeyIcon.tsx
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import clsx from 'clsx';
|
|
2
|
+
import { KeyRound } from 'lucide-react';
|
|
3
|
+
import { Icon } from '@adecore/ui';
|
|
4
|
+
import type { ColumnKeys } from './column-keys.ts';
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* The key of a column, yellow on a primary key and blue on a foreign key, the same in the tree and the
|
|
8
|
+
* grid. A column that is both shows the primary key, since that is what names its row; the tooltip says both.
|
|
9
|
+
*/
|
|
10
|
+
export function KeyIcon({ primaryKey, foreignKey, size }: ColumnKeys & { size: 12 | 14 }) {
|
|
11
|
+
if (!primaryKey && !foreignKey) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
return <Icon icon={KeyRound} size={size} className={clsx('shrink-0', primaryKey ? 'text-(--file-icon-yellow)' : 'text-(--file-icon-blue)')} />;
|
|
15
|
+
}
|
package/src/actions.ts
CHANGED
|
@@ -1,18 +1,44 @@
|
|
|
1
1
|
import type { TableRef } from './client/types.ts';
|
|
2
|
+
import type { TableKind } from './protocol/index.ts';
|
|
2
3
|
|
|
3
4
|
/*
|
|
4
5
|
* Something a view asks the app to open. The views draw no tabs and no windows of their own: the app
|
|
5
6
|
* decides where a table, a console or the designer lands, such as a tab beside its file tabs.
|
|
6
7
|
*/
|
|
7
8
|
export type DatabaseAction =
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
| {
|
|
10
|
+
readonly kind: 'open-table';
|
|
11
|
+
readonly ref: TableRef;
|
|
12
|
+
readonly view: 'data' | 'structure';
|
|
13
|
+
/* Opens the table filtered, as a jump along a foreign key does. */
|
|
14
|
+
readonly where?: string;
|
|
15
|
+
/* Whether `ref` is a table or a view, where the sender knows it. */
|
|
16
|
+
readonly tableKind?: TableKind;
|
|
17
|
+
/*
|
|
18
|
+
* Only from an explorer that opens on a click: `true` for that click, a look a person may move on from, and
|
|
19
|
+
* `false` for a double click or Enter, which mean to keep the table open.
|
|
20
|
+
*/
|
|
21
|
+
readonly preview?: boolean;
|
|
22
|
+
}
|
|
10
23
|
| { readonly kind: 'open-console'; readonly connectionId: string; readonly schema?: string; readonly sql?: string }
|
|
11
24
|
| { readonly kind: 'new-table'; readonly connectionId: string; readonly schema: string }
|
|
12
25
|
| { readonly kind: 'edit-table'; readonly ref: TableRef }
|
|
13
26
|
/* The app opens its connection manager on this connection. */
|
|
14
27
|
| { readonly kind: 'manage-connection'; readonly connectionId: string };
|
|
15
28
|
|
|
29
|
+
/*
|
|
30
|
+
* Something a view tells a person that passes, such as an export that finished or failed, for the app to show
|
|
31
|
+
* where it shows such news. What stays part of a view, such as a conflict or a read-only reason, is no notice.
|
|
32
|
+
*/
|
|
33
|
+
export interface DatabaseNotice {
|
|
34
|
+
readonly tone: 'success' | 'error';
|
|
35
|
+
/* What happened in a few words, such as "Exported 1,884 rows." or "Export failed". */
|
|
36
|
+
readonly title: string;
|
|
37
|
+
/* Why it failed, as the server or the file system said it. */
|
|
38
|
+
readonly description?: string;
|
|
39
|
+
readonly action?: { readonly label: string; run(): void };
|
|
40
|
+
}
|
|
41
|
+
|
|
16
42
|
/* What is selected in the explorer: a connection, a schema in it, or a table in that schema. */
|
|
17
43
|
export interface ExplorerSelection {
|
|
18
44
|
readonly connectionId: string;
|
package/src/client-context.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { createContext, use } from 'react';
|
|
2
|
-
import type { DatabaseAction, DatabaseFiles, DatabaseStorage } from './actions.ts';
|
|
2
|
+
import type { DatabaseAction, DatabaseFiles, DatabaseNotice, DatabaseStorage } from './actions.ts';
|
|
3
3
|
import type { DatabaseClient } from './client/types.ts';
|
|
4
4
|
import type { NumberNotation } from './grid/display.ts';
|
|
5
5
|
|
|
6
6
|
export interface DatabaseContextValue {
|
|
7
7
|
readonly client: DatabaseClient;
|
|
8
8
|
readonly onAction: ((action: DatabaseAction) => void) | undefined;
|
|
9
|
+
readonly onNotice: ((notice: DatabaseNotice) => void) | undefined;
|
|
9
10
|
readonly storage: DatabaseStorage | undefined;
|
|
10
11
|
readonly files: DatabaseFiles | undefined;
|
|
11
12
|
readonly numberNotation: NumberNotation;
|
|
@@ -26,6 +27,9 @@ export const useDatabaseClient = (): DatabaseClient => useDatabaseContext().clie
|
|
|
26
27
|
/* Hands an action to the app, or `undefined` when the app takes none, so a view can leave out what would go nowhere. */
|
|
27
28
|
export const useDatabaseAction = (): ((action: DatabaseAction) => void) | undefined => useDatabaseContext().onAction;
|
|
28
29
|
|
|
30
|
+
/* Hands a notice to the app, or `undefined` when the app takes none and the view shows it itself. */
|
|
31
|
+
export const useDatabaseNotice = (): ((notice: DatabaseNotice) => void) | undefined => useDatabaseContext().onNotice;
|
|
32
|
+
|
|
29
33
|
export const useDatabaseStorage = (): DatabaseStorage | undefined => useDatabaseContext().storage;
|
|
30
34
|
|
|
31
35
|
/* The app's file dialogs; export and import stay hidden without them. */
|
package/src/code-text.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface ColumnKeys {
|
|
2
|
+
readonly primaryKey: boolean;
|
|
3
|
+
readonly foreignKey: boolean;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/* The words that name what a key column is, for its tooltip. */
|
|
7
|
+
export const keyLabelOf = ({ primaryKey, foreignKey }: ColumnKeys): 'explorer.primaryAndForeignKey' | 'explorer.primaryKey' | 'explorer.foreignKey' =>
|
|
8
|
+
primaryKey && foreignKey ? 'explorer.primaryAndForeignKey' : primaryKey ? 'explorer.primaryKey' : 'explorer.foreignKey';
|
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
import { useState, type Ref } from 'react';
|
|
1
|
+
import { useState, type ReactNode, type Ref } from 'react';
|
|
2
|
+
import clsx from 'clsx';
|
|
2
3
|
import { CircleAlert, CircleCheck, Container, Database, Plus, Trash2, Unplug } from 'lucide-react';
|
|
3
4
|
import { useTranslation } from 'react-i18next';
|
|
4
|
-
import { Banner, Button, EmptyState, Icon, Menu, messageOf, PromptDialog, Spinner } from '@adecore/ui';
|
|
5
|
-
import { DetailHeader,
|
|
5
|
+
import { Banner, Button, CloseButton, EmptyState, Icon, Menu, messageOf, PromptDialog, Spinner } from '@adecore/ui';
|
|
6
|
+
import { DetailHeader, MasterItem } from '@adecore/ui/settings';
|
|
6
7
|
import { useDatabaseClient } from '../client-context.ts';
|
|
7
8
|
import type { Connection } from '../client/types.ts';
|
|
8
9
|
import type { ConnectionConfig, DockerContainer, Engine, ServerInfo } from '../protocol/index.ts';
|
|
9
10
|
import { defaultConfig, isValidConfig, targetOf } from './connection-config.ts';
|
|
10
11
|
import { ConnectionForm } from './ConnectionForm.tsx';
|
|
11
|
-
import {
|
|
12
|
+
import { connectionFromContainer, containerTitle, isMysqlContainer } from './docker.ts';
|
|
12
13
|
import { useDockerContainers } from './use-docker-containers.ts';
|
|
13
14
|
import { ENGINE_ICONS } from './engine-icons.ts';
|
|
14
15
|
import { EngineIcon } from './EngineIcon.tsx';
|
|
@@ -24,20 +25,33 @@ export interface ConnectionManagerProps {
|
|
|
24
25
|
onSelectedChange?(id: string | null): void;
|
|
25
26
|
/* Opens the app's file dialog for a database file or an SSH identity file; without it the path is typed. See `ConnectionForm`. */
|
|
26
27
|
onBrowse?(purpose: 'database' | 'identity'): Promise<string | null>;
|
|
28
|
+
/* The app's own settings of the picked connection, drawn after the form's fields and in their rhythm, such as a horizontal `Field`. */
|
|
29
|
+
renderFields?(connection: Connection): ReactNode;
|
|
27
30
|
className?: string;
|
|
28
31
|
ref?: Ref<HTMLDivElement>;
|
|
29
32
|
}
|
|
30
33
|
|
|
31
|
-
|
|
34
|
+
/* What the server answered a test, for the connection and the config that were tested. */
|
|
35
|
+
type TestOutcome = { readonly connectionId: string; readonly config: ConnectionConfig } & ({ readonly info: ServerInfo } | { readonly error: string });
|
|
32
36
|
|
|
33
|
-
/*
|
|
34
|
-
|
|
37
|
+
/*
|
|
38
|
+
* The saved connections beside the form of the one picked, for a dialog, a settings pane or a view of its own. The list
|
|
39
|
+
* scrolls on its own with New connection under it, and the detail scrolls beside it; in a narrow window they stack.
|
|
40
|
+
*/
|
|
41
|
+
export function ConnectionManager({ value, onValueChange, selected, onSelectedChange, onBrowse, renderFields, className, ref }: ConnectionManagerProps) {
|
|
35
42
|
const { t } = useTranslation('database');
|
|
36
43
|
const [own, setOwn] = useState<string | null>(null);
|
|
37
44
|
const [deleting, setDeleting] = useState(false);
|
|
45
|
+
const [outcome, setOutcome] = useState<TestOutcome | null>(null);
|
|
38
46
|
const client = useDatabaseClient();
|
|
39
47
|
const pickedId = selected === undefined ? own : selected;
|
|
40
48
|
const current = value.find((connection) => connection.id === pickedId) ?? value[0] ?? null;
|
|
49
|
+
// The answer of one connection is gone once another is picked, so coming back does not bring it back.
|
|
50
|
+
if (outcome !== null && outcome.connectionId !== current?.id) {
|
|
51
|
+
setOutcome(null);
|
|
52
|
+
}
|
|
53
|
+
// An answer to an older config is never shown for a newer one.
|
|
54
|
+
const shown = outcome !== null && current !== null && outcome.config === current.config ? outcome : null;
|
|
41
55
|
|
|
42
56
|
const select = (id: string | null): void => {
|
|
43
57
|
if (selected === undefined) {
|
|
@@ -71,41 +85,46 @@ export function ConnectionManager({ value, onValueChange, selected, onSelectedCh
|
|
|
71
85
|
};
|
|
72
86
|
|
|
73
87
|
const list = (
|
|
74
|
-
|
|
75
|
-
<
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
{
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
88
|
+
<div className="flex w-56 shrink-0 flex-col border-r border-border max-[640px]:max-h-60 max-[640px]:w-auto max-[640px]:border-r-0 max-[640px]:border-b">
|
|
89
|
+
<nav aria-label={t('connections.list')} className="flex min-h-0 grow flex-col gap-0.5 overflow-y-auto p-2">
|
|
90
|
+
{value.length === 0 && <p className="px-2 py-1.5 text-xs text-text-faint">{t('connections.empty')}</p>}
|
|
91
|
+
{value.map((connection) => (
|
|
92
|
+
<MasterItem key={connection.id} selected={connection.id === current?.id} onSelect={() => select(connection.id)}>
|
|
93
|
+
<EngineIcon engine={connection.config.engine} size={16} className="shrink-0 text-text-muted" />
|
|
94
|
+
<span className="flex min-w-0 flex-col">
|
|
95
|
+
<span className="truncate">{connection.name || t('connections.untitled')}</span>
|
|
96
|
+
<span className="truncate text-xs text-text-faint">{targetOf(connection)}</span>
|
|
97
|
+
</span>
|
|
98
|
+
</MasterItem>
|
|
99
|
+
))}
|
|
100
|
+
</nav>
|
|
101
|
+
<div className="flex shrink-0 flex-col gap-1 border-t border-border p-2">
|
|
102
|
+
<Menu.Root>
|
|
103
|
+
<Menu.Trigger render={<Button variant="ghost" size="sm" className="justify-start" />}>
|
|
104
|
+
<Icon icon={Plus} size={14} />
|
|
105
|
+
{t('connections.new')}
|
|
106
|
+
</Menu.Trigger>
|
|
107
|
+
<Menu.Popup>
|
|
108
|
+
{ENGINES.map((engine) => (
|
|
109
|
+
<Menu.Item key={engine} onClick={() => add(engine)}>
|
|
110
|
+
<Icon icon={ENGINE_ICONS[engine]} size={14} />
|
|
111
|
+
{t(`engine.${engine}`)}
|
|
112
|
+
</Menu.Item>
|
|
113
|
+
))}
|
|
114
|
+
<Menu.Separator />
|
|
115
|
+
<Menu.SubmenuRoot>
|
|
116
|
+
<Menu.SubmenuTrigger>
|
|
117
|
+
<Icon icon={Container} size={14} />
|
|
118
|
+
{t('connections.fromDocker.label')}
|
|
119
|
+
</Menu.SubmenuTrigger>
|
|
120
|
+
<Menu.Popup>
|
|
121
|
+
<DockerMenuItems onPick={addFromContainer} />
|
|
122
|
+
</Menu.Popup>
|
|
123
|
+
</Menu.SubmenuRoot>
|
|
124
|
+
</Menu.Popup>
|
|
125
|
+
</Menu.Root>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
109
128
|
);
|
|
110
129
|
|
|
111
130
|
const detail =
|
|
@@ -113,6 +132,20 @@ export function ConnectionManager({ value, onValueChange, selected, onSelectedCh
|
|
|
113
132
|
<EmptyState icon={Database}>{t('connections.empty')}</EmptyState>
|
|
114
133
|
) : (
|
|
115
134
|
<>
|
|
135
|
+
{shown !== null && (
|
|
136
|
+
<Banner
|
|
137
|
+
icon={'info' in shown ? CircleCheck : CircleAlert}
|
|
138
|
+
tone={'info' in shown ? 'neutral' : 'error'}
|
|
139
|
+
className="sticky top-0 z-10"
|
|
140
|
+
message={
|
|
141
|
+
'info' in shown
|
|
142
|
+
? t('connections.test.success', { server: `${t(`engine.flavor.${shown.info.flavor}`)} ${shown.info.version}` })
|
|
143
|
+
: shown.error
|
|
144
|
+
}
|
|
145
|
+
>
|
|
146
|
+
<CloseButton size="sm" label={t('connections.test.dismiss')} onClick={() => setOutcome(null)} />
|
|
147
|
+
</Banner>
|
|
148
|
+
)}
|
|
116
149
|
<DetailHeader
|
|
117
150
|
mark={<EngineIcon engine={current.config.engine} size={20} className="mt-0.5 shrink-0 text-text-muted" />}
|
|
118
151
|
title={current.name || t('connections.untitled')}
|
|
@@ -124,8 +157,11 @@ export function ConnectionManager({ value, onValueChange, selected, onSelectedCh
|
|
|
124
157
|
</Button>
|
|
125
158
|
}
|
|
126
159
|
/>
|
|
127
|
-
<
|
|
128
|
-
|
|
160
|
+
<div className="flex flex-col gap-4">
|
|
161
|
+
<ConnectionForm value={current} onValueChange={change} onBrowse={onBrowse} />
|
|
162
|
+
{renderFields?.(current)}
|
|
163
|
+
</div>
|
|
164
|
+
<ConnectionTest key={current.id} connection={current} onOutcome={setOutcome} />
|
|
129
165
|
<PromptDialog
|
|
130
166
|
open={deleting}
|
|
131
167
|
danger
|
|
@@ -141,7 +177,14 @@ export function ConnectionManager({ value, onValueChange, selected, onSelectedCh
|
|
|
141
177
|
</>
|
|
142
178
|
);
|
|
143
179
|
|
|
144
|
-
return
|
|
180
|
+
return (
|
|
181
|
+
<div ref={ref} className={clsx('flex min-h-0 min-w-0 grow max-[640px]:flex-col', className)}>
|
|
182
|
+
{list}
|
|
183
|
+
<div className="min-h-0 min-w-0 grow overflow-y-auto">
|
|
184
|
+
<div className="flex min-w-0 flex-col gap-6 px-5 py-4">{detail}</div>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
);
|
|
145
188
|
}
|
|
146
189
|
|
|
147
190
|
/* The containers Docker is running as menu items. The submenu mounts this when it opens, so Docker is asked only then. */
|
|
@@ -175,50 +218,37 @@ function DockerMenuItems({ onPick }: { onPick(container: DockerContainer): void
|
|
|
175
218
|
return containers.map((container) => (
|
|
176
219
|
<Menu.Item key={container.id} onClick={() => onPick(container)}>
|
|
177
220
|
<span className="flex min-w-0 flex-col">
|
|
178
|
-
<span className="truncate">{
|
|
221
|
+
<span className="truncate">{containerTitle(container)}</span>
|
|
179
222
|
<span className="truncate text-xs text-text-faint">{container.image}</span>
|
|
180
223
|
</span>
|
|
181
224
|
</Menu.Item>
|
|
182
225
|
));
|
|
183
226
|
}
|
|
184
227
|
|
|
185
|
-
/* The button that asks the server who it is
|
|
186
|
-
function ConnectionTest({ connection }: { connection: Connection }) {
|
|
228
|
+
/* The button that asks the server who it is. The manager shows the answer at the top of the detail. */
|
|
229
|
+
function ConnectionTest({ connection, onOutcome }: { connection: Connection; onOutcome(outcome: TestOutcome): void }) {
|
|
187
230
|
const { t } = useTranslation('database');
|
|
188
231
|
const client = useDatabaseClient();
|
|
189
232
|
const [busy, setBusy] = useState(false);
|
|
190
|
-
const [outcome, setOutcome] = useState<TestOutcome | null>(null);
|
|
191
|
-
const shown = outcome !== null && outcome.config === connection.config ? outcome : null;
|
|
192
233
|
|
|
193
234
|
const test = async (): Promise<void> => {
|
|
194
235
|
const config = connection.config;
|
|
195
236
|
setBusy(true);
|
|
196
237
|
try {
|
|
197
|
-
|
|
238
|
+
onOutcome({ connectionId: connection.id, config, info: await client.test(config) });
|
|
198
239
|
} catch (e) {
|
|
199
|
-
|
|
240
|
+
onOutcome({ connectionId: connection.id, config, error: messageOf(e, t('connections.test.failed')) });
|
|
200
241
|
} finally {
|
|
201
242
|
setBusy(false);
|
|
202
243
|
}
|
|
203
244
|
};
|
|
204
245
|
|
|
205
246
|
return (
|
|
206
|
-
<div
|
|
207
|
-
<
|
|
208
|
-
<
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
</Button>
|
|
212
|
-
</div>
|
|
213
|
-
{shown !== null && 'info' in shown && (
|
|
214
|
-
<Banner
|
|
215
|
-
icon={CircleCheck}
|
|
216
|
-
tone="neutral"
|
|
217
|
-
className="w-full"
|
|
218
|
-
message={t('connections.test.success', { server: `${t(`engine.flavor.${shown.info.flavor}`)} ${shown.info.version}` })}
|
|
219
|
-
/>
|
|
220
|
-
)}
|
|
221
|
-
{shown !== null && 'error' in shown && <Banner icon={CircleAlert} tone="error" className="w-full" message={shown.error} />}
|
|
247
|
+
<div>
|
|
248
|
+
<Button variant="secondary" disabled={busy || !isValidConfig(connection.config)} onClick={() => void test()}>
|
|
249
|
+
{busy ? <Spinner size={14} /> : <Icon icon={Unplug} size={14} />}
|
|
250
|
+
{t('connections.test.action')}
|
|
251
|
+
</Button>
|
|
222
252
|
</div>
|
|
223
253
|
);
|
|
224
254
|
}
|
|
@@ -82,8 +82,10 @@ const mysqlProblems = (config: MysqlConnectionConfig): ConfigProblems => {
|
|
|
82
82
|
return problems;
|
|
83
83
|
};
|
|
84
84
|
|
|
85
|
+
/* What is wrong with a config, field by field: the codes the form shows. Empty for a config with nothing to fix. */
|
|
85
86
|
export const configProblems = (config: ConnectionConfig): ConfigProblems => (config.engine === 'sqlite' ? sqliteProblems(config) : mysqlProblems(config));
|
|
86
87
|
|
|
88
|
+
/* Whether a connection can be saved, rather than a draft a person is still filling in. */
|
|
87
89
|
export const isValidConfig = (config: ConnectionConfig): boolean => Object.keys(configProblems(config)).length === 0;
|
|
88
90
|
|
|
89
91
|
const lastSegment = (path: string): string => {
|