@adecore/database 0.21.0 → 0.21.2
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/dist/assign-ref.d.ts +2 -0
- package/dist/assign-ref.js +9 -0
- package/dist/client/client.js +14 -15
- package/dist/client/types.d.ts +1 -0
- package/dist/client/types.js +2 -0
- package/dist/connections/ConnectionForm.js +7 -2
- package/dist/connections/ConnectionManager.js +5 -5
- package/dist/connections/EngineIcon.js +0 -1
- package/dist/console/QueryConsole.js +12 -4
- package/dist/console/editing.js +2 -4
- package/dist/console/useConsoleHistory.js +3 -2
- package/dist/ddl/alter-mysql.js +2 -8
- package/dist/ddl/alter-sqlite.js +1 -1
- package/dist/ddl/column.js +5 -8
- package/dist/ddl/create.d.ts +1 -0
- package/dist/ddl/create.js +1 -1
- package/dist/ddl/diff.js +25 -23
- package/dist/designer/ColumnsEditor.js +8 -6
- package/dist/designer/ForeignKeysEditor.js +5 -1
- package/dist/designer/OptionsEditor.js +12 -3
- package/dist/designer/SqlPreview.d.ts +1 -5
- package/dist/designer/SqlPreview.js +1 -1
- package/dist/designer/TableDesigner.js +7 -6
- package/dist/explorer/DatabaseExplorer.js +12 -13
- package/dist/explorer/TableDialog.js +1 -1
- package/dist/explorer/tree.js +6 -16
- package/dist/grid/DataGrid.js +11 -14
- package/dist/grid/EnumPicker.js +4 -3
- package/dist/grid/GridHeaderCell.d.ts +1 -1
- package/dist/grid/GridHeaderCell.js +2 -2
- package/dist/grid/RecordView.js +2 -1
- package/dist/grid/ValueDock.js +2 -6
- package/dist/grid/aggregates.js +2 -3
- package/dist/grid/column-layout.js +3 -2
- package/dist/grid/copy-formats.js +2 -3
- package/dist/grid/display.d.ts +0 -1
- package/dist/grid/display.js +1 -3
- package/dist/grid/layout.js +6 -11
- package/dist/host/helper-path.js +8 -0
- package/dist/host/host.js +11 -16
- package/dist/host/validate.js +44 -43
- package/dist/sql.d.ts +2 -0
- package/dist/sql.js +5 -2
- package/dist/structure/StructureView.js +5 -6
- package/dist/table/TableView.js +19 -12
- package/dist/table/command-field.js +8 -12
- package/dist/table/grid-rows.js +2 -1
- package/dist/table/layout-store.js +5 -2
- package/dist/table/pending.js +11 -3
- package/dist/table/useTableTransfer.js +6 -5
- package/dist/testing/fake.js +7 -9
- package/dist/value/ValueEditor.js +1 -1
- package/dist/value/draft.js +1 -6
- package/dist/value/json.js +4 -4
- package/dist/workbench/DatabaseWorkbench.js +12 -5
- package/dist/workbench/tabs.js +3 -4
- package/package.json +6 -6
- package/src/assign-ref.ts +10 -0
- package/src/client/client.ts +17 -18
- package/src/client/types.ts +3 -0
- package/src/connections/ConnectionForm.tsx +62 -47
- package/src/connections/ConnectionManager.tsx +6 -5
- package/src/connections/EngineIcon.tsx +0 -2
- package/src/console/QueryConsole.tsx +52 -31
- package/src/console/editing.ts +2 -4
- package/src/console/useConsoleHistory.ts +5 -2
- package/src/ddl/alter-mysql.ts +3 -11
- package/src/ddl/alter-sqlite.ts +1 -1
- package/src/ddl/column.ts +5 -7
- package/src/ddl/create.ts +1 -1
- package/src/ddl/diff.ts +30 -25
- package/src/designer/ColumnsEditor.tsx +145 -125
- package/src/designer/ForeignKeysEditor.tsx +44 -27
- package/src/designer/OptionsEditor.tsx +95 -75
- package/src/designer/SqlPreview.tsx +2 -3
- package/src/designer/TableDesigner.tsx +7 -6
- package/src/explorer/DatabaseExplorer.tsx +26 -31
- package/src/explorer/TableDialog.tsx +1 -1
- package/src/explorer/tree.ts +8 -19
- package/src/grid/DataGrid.tsx +11 -14
- package/src/grid/EnumPicker.tsx +4 -3
- package/src/grid/GridHeaderCell.tsx +2 -3
- package/src/grid/RecordView.tsx +2 -1
- package/src/grid/ValueDock.tsx +2 -5
- package/src/grid/aggregates.ts +2 -4
- package/src/grid/column-layout.ts +3 -2
- package/src/grid/copy-formats.ts +2 -3
- package/src/grid/display.ts +1 -5
- package/src/grid/layout.ts +10 -11
- package/src/host/helper-path.ts +8 -8
- package/src/host/host.ts +11 -16
- package/src/host/validate.ts +45 -43
- package/src/protocol/messages.ts +0 -1
- package/src/sql.ts +7 -2
- package/src/structure/StructureView.tsx +7 -8
- package/src/table/CommandField.tsx +0 -1
- package/src/table/TableView.tsx +48 -34
- package/src/table/command-field.ts +9 -12
- package/src/table/grid-rows.ts +2 -7
- package/src/table/layout-store.ts +6 -3
- package/src/table/pending.ts +12 -7
- package/src/table/useTableTransfer.ts +7 -5
- package/src/testing/fake.ts +8 -12
- package/src/value/ValueEditor.tsx +1 -1
- package/src/value/draft.ts +1 -6
- package/src/value/json.ts +4 -4
- package/src/workbench/DatabaseWorkbench.tsx +14 -6
- package/src/workbench/DesignerTab.tsx +1 -1
- package/src/workbench/tabs.ts +5 -8
|
@@ -10,12 +10,18 @@ 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';
|
|
13
|
+
import { useStableCallback } from '../use-stable-callback.js';
|
|
13
14
|
import { DesignerTab } from './DesignerTab.js';
|
|
14
15
|
import { TableTab } from './TableTab.js';
|
|
15
16
|
import { closeTab, connectionIdOf, focusTab, openConsole, openDesigner, openTable, parseState, pruneTabs, serializeState, setConsoleSql, setDesignerTable, setTableView, STORAGE_KEY } from './tabs.js';
|
|
16
|
-
import { useStableCallback } from '../use-stable-callback.js';
|
|
17
17
|
const DEFAULT_WIDTH = 280;
|
|
18
18
|
const MIN_WIDTH = 200;
|
|
19
|
+
const CLOSE_TAB = shortcut('Mod+W');
|
|
20
|
+
const TAB_ICON = {
|
|
21
|
+
table: Table,
|
|
22
|
+
console: SquareTerminal,
|
|
23
|
+
designer: PencilRuler
|
|
24
|
+
};
|
|
19
25
|
/*
|
|
20
26
|
* The whole database view in one piece: the explorer beside closable tabs for the tables, consoles and
|
|
21
27
|
* designers a person opens. It answers the actions of the views below itself; an action it cannot place
|
|
@@ -41,8 +47,9 @@ export function DatabaseWorkbench({ connections, onConnectionsChange, className,
|
|
|
41
47
|
if (pruned !== state) {
|
|
42
48
|
setState(pruned);
|
|
43
49
|
}
|
|
44
|
-
|
|
45
|
-
|
|
50
|
+
const isOpen = (id) => state.tabs.some((tab) => tab.id === id);
|
|
51
|
+
if (![...dirty].every(isOpen)) {
|
|
52
|
+
setDirty(new Set([...dirty].filter(isOpen)));
|
|
46
53
|
}
|
|
47
54
|
const tabs = state.tabs.filter((tab) => known.has(connectionIdOf(tab)));
|
|
48
55
|
const active = tabs.find((tab) => tab.id === state.activeId) ?? tabs.at(-1) ?? null;
|
|
@@ -98,7 +105,7 @@ export function DatabaseWorkbench({ connections, onConnectionsChange, className,
|
|
|
98
105
|
if (active !== null &&
|
|
99
106
|
e.currentTarget.contains(e.target) &&
|
|
100
107
|
!e.nativeEvent.isComposing &&
|
|
101
|
-
matchesShortcut(
|
|
108
|
+
matchesShortcut(CLOSE_TAB, e.nativeEvent, isApplePlatform())) {
|
|
102
109
|
e.preventDefault();
|
|
103
110
|
requestClose(active.id);
|
|
104
111
|
}
|
|
@@ -163,7 +170,7 @@ export function DatabaseWorkbench({ connections, onConnectionsChange, className,
|
|
|
163
170
|
} }))] }), _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: [
|
|
164
171
|
{ id: 'data', label: t('workbench.data'), icon: Table },
|
|
165
172
|
{ id: 'structure', label: t('workbench.structure'), icon: TableProperties }
|
|
166
|
-
] })), _jsx(IconButton, { icon: SquareTerminal, size: "sm", label: t('workbench.newConsole'), tooltip: canOpenConsole ? undefined : t('workbench.noConnection'), disabled: !canOpenConsole, onClick: newConsole })] }), children: tabs.map((tab) => (_jsxs(Tabs.Tab, { value: tab.id, onClose: () => requestClose(tab.id), children: [_jsx(Icon, { icon: tab.kind
|
|
173
|
+
] })), _jsx(IconButton, { icon: SquareTerminal, size: "sm", label: t('workbench.newConsole'), tooltip: canOpenConsole ? undefined : t('workbench.noConnection'), disabled: !canOpenConsole, onClick: newConsole })] }), children: tabs.map((tab) => (_jsxs(Tabs.Tab, { value: tab.id, onClose: () => requestClose(tab.id), children: [_jsx(Icon, { icon: TAB_ICON[tab.kind], size: 14 }), titleOf(tab)] }, tab.id))) }), tabs.length === 0 ? (_jsx(EmptyState, { icon: SquareTerminal, title: t('workbench.empty.title'), className: "flex-1", action: _jsx(Button, { variant: "secondary", disabled: !canOpenConsole, onClick: newConsole, children: t('workbench.newConsole') }), children: t('workbench.empty.body') })) : (tabs.map((tab) => (_jsx(Tabs.Panel, { value: tab.id, keepMounted: true, className: "min-h-0 flex-1", children: viewOf(tab) }, tab.id))))] }), _jsx(PromptDialog, { open: closing !== null, danger: true, title: t('workbench.discard.title', { table: closingTable }), description: t('workbench.discard.description'), confirmLabel: t('workbench.discard.confirm'), onConfirm: () => {
|
|
167
174
|
if (closing !== null) {
|
|
168
175
|
closeById(closing);
|
|
169
176
|
}
|
package/dist/workbench/tabs.js
CHANGED
|
@@ -2,6 +2,7 @@ export const STORAGE_KEY = 'database:workbench';
|
|
|
2
2
|
export const emptyState = { tabs: [], activeId: null, serial: 0, consoles: 0 };
|
|
3
3
|
export const connectionIdOf = (tab) => (tab.kind === 'table' ? tab.ref.connectionId : tab.connectionId);
|
|
4
4
|
const tableTabId = (ref) => `table:${ref.connectionId}\u0000${ref.schema}\u0000${ref.table}`;
|
|
5
|
+
const isDesignerOf = (tab, connectionId, schema, table) => tab.kind === 'designer' && tab.connectionId === connectionId && tab.schema === schema && tab.table === table;
|
|
5
6
|
const add = (state, tab) => ({ ...state, tabs: [...state.tabs, tab], activeId: tab.id });
|
|
6
7
|
const update = (state, id, change) => {
|
|
7
8
|
let changed = false;
|
|
@@ -35,9 +36,7 @@ export const openConsole = (state, connectionId, schema, sql = '') => {
|
|
|
35
36
|
};
|
|
36
37
|
/* A table is designed in one tab; a new table gets a tab each time. */
|
|
37
38
|
export const openDesigner = (state, connectionId, schema, table) => {
|
|
38
|
-
const existing = table === undefined
|
|
39
|
-
? undefined
|
|
40
|
-
: state.tabs.find((tab) => tab.kind === 'designer' && tab.connectionId === connectionId && tab.schema === schema && tab.table === table);
|
|
39
|
+
const existing = table === undefined ? undefined : state.tabs.find((tab) => isDesignerOf(tab, connectionId, schema, table));
|
|
41
40
|
if (existing !== undefined) {
|
|
42
41
|
return focusTab(state, existing.id);
|
|
43
42
|
}
|
|
@@ -52,7 +51,7 @@ export const setDesignerTable = (state, id, table) => {
|
|
|
52
51
|
if (subject?.kind !== 'designer' || subject.table === table) {
|
|
53
52
|
return state;
|
|
54
53
|
}
|
|
55
|
-
const tabs = state.tabs.filter((tab) => tab.id === id || !(tab
|
|
54
|
+
const tabs = state.tabs.filter((tab) => tab.id === id || !isDesignerOf(tab, subject.connectionId, subject.schema, table));
|
|
56
55
|
return update({ ...state, tabs }, id, (tab) => (tab.kind === 'designer' ? { ...tab, table } : tab));
|
|
57
56
|
};
|
|
58
57
|
/* The tab next to the closed one comes forward when the closed one was in front. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adecore/database",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.2",
|
|
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.21.
|
|
65
|
-
"@adecore/database-linux-arm64": "0.21.
|
|
66
|
-
"@adecore/database-linux-x64": "0.21.
|
|
67
|
-
"@adecore/database-win32-x64": "0.21.
|
|
64
|
+
"@adecore/database-darwin-arm64": "0.21.2",
|
|
65
|
+
"@adecore/database-linux-arm64": "0.21.2",
|
|
66
|
+
"@adecore/database-linux-x64": "0.21.2",
|
|
67
|
+
"@adecore/database-win32-x64": "0.21.2"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
70
|
-
"@adecore/ui": "^0.21.
|
|
70
|
+
"@adecore/ui": "^0.21.2",
|
|
71
71
|
"i18next": "^26.4.2",
|
|
72
72
|
"react": "^19.3.0",
|
|
73
73
|
"react-dom": "^19.3.0",
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Ref } from 'react';
|
|
2
|
+
|
|
3
|
+
/* Hands a node to a ref of either kind, for an element that keeps a ref of its own beside the one it was given. */
|
|
4
|
+
export function assignRef<T>(ref: Ref<T> | undefined, node: T | null): void {
|
|
5
|
+
if (typeof ref === 'function') {
|
|
6
|
+
ref(node);
|
|
7
|
+
} else if (ref) {
|
|
8
|
+
ref.current = node;
|
|
9
|
+
}
|
|
10
|
+
}
|
package/src/client/client.ts
CHANGED
|
@@ -25,9 +25,9 @@ const cancelledError = (): DatabaseRequestError => new DatabaseRequestError('can
|
|
|
25
25
|
const isUnknownSession = (error: unknown): boolean => error instanceof DatabaseRequestError && error.code === 'unknown-session';
|
|
26
26
|
|
|
27
27
|
const LEADING_COMMENTS = /^(?:\s+|--[^\n]*(?:\n|$)|#[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)+/;
|
|
28
|
-
const
|
|
28
|
+
const SCHEMA_STATEMENT = /^(?:create|alter|drop|rename|truncate)\b/i;
|
|
29
29
|
|
|
30
|
-
const
|
|
30
|
+
const changesSchema = (sql: string): boolean => SCHEMA_STATEMENT.test(sql.replace(LEADING_COMMENTS, ''));
|
|
31
31
|
|
|
32
32
|
/* Structural equality for the JSON a config is made of; a key set to `undefined` counts as absent. */
|
|
33
33
|
const isEqual = (left: unknown, right: unknown): boolean => {
|
|
@@ -71,6 +71,17 @@ export const createDatabaseClient = (transport: DatabaseTransport, options: Data
|
|
|
71
71
|
const live = new Map<string, Map<string, ReturnType<typeof createSession>>>();
|
|
72
72
|
const schemaListeners = new Set<(change: SchemaChange) => void>();
|
|
73
73
|
|
|
74
|
+
const channelsOf = (connectionId: string): Map<string, ReturnType<typeof createSession>> => {
|
|
75
|
+
let channels = live.get(connectionId);
|
|
76
|
+
|
|
77
|
+
if (channels === undefined) {
|
|
78
|
+
channels = new Map();
|
|
79
|
+
live.set(connectionId, channels);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return channels;
|
|
83
|
+
};
|
|
84
|
+
|
|
74
85
|
const notifySchemaChange = (change: SchemaChange): void => {
|
|
75
86
|
for (const listener of [...schemaListeners]) {
|
|
76
87
|
try {
|
|
@@ -127,12 +138,7 @@ export const createDatabaseClient = (transport: DatabaseTransport, options: Data
|
|
|
127
138
|
|
|
128
139
|
/* A session that was closed and is used again is findable again, so `disconnect` and `dispose` reach it. */
|
|
129
140
|
const track = (): void => {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if (channels === undefined) {
|
|
133
|
-
channels = new Map();
|
|
134
|
-
live.set(connection.id, channels);
|
|
135
|
-
}
|
|
141
|
+
const channels = channelsOf(connection.id);
|
|
136
142
|
|
|
137
143
|
if (!channels.has(channel)) {
|
|
138
144
|
channels.set(channel, created);
|
|
@@ -240,7 +246,7 @@ export const createDatabaseClient = (transport: DatabaseTransport, options: Data
|
|
|
240
246
|
const { signal: _signal, ...rest } = executeOptions ?? {};
|
|
241
247
|
const result = await request('execute', (id) => ({ session: id, sql, ...rest }), executeOptions, false);
|
|
242
248
|
|
|
243
|
-
if (result.results.some((item) => item.kind !== 'error' &&
|
|
249
|
+
if (result.results.some((item) => item.kind !== 'error' && changesSchema(item.sql))) {
|
|
244
250
|
notifySchemaChange({ connectionId: connection.id, ...(rest.schema === undefined ? {} : { schema: rest.schema }) });
|
|
245
251
|
}
|
|
246
252
|
|
|
@@ -290,8 +296,7 @@ export const createDatabaseClient = (transport: DatabaseTransport, options: Data
|
|
|
290
296
|
};
|
|
291
297
|
},
|
|
292
298
|
session(connection, channel = '') {
|
|
293
|
-
|
|
294
|
-
const existing = channels?.get(channel);
|
|
299
|
+
const existing = live.get(connection.id)?.get(channel);
|
|
295
300
|
|
|
296
301
|
if (existing !== undefined && isEqual(existing.session.connection.config, connection.config)) {
|
|
297
302
|
existing.update(connection);
|
|
@@ -301,13 +306,7 @@ export const createDatabaseClient = (transport: DatabaseTransport, options: Data
|
|
|
301
306
|
void existing?.close();
|
|
302
307
|
|
|
303
308
|
const created = createSession(connection, channel);
|
|
304
|
-
|
|
305
|
-
if (channels === undefined) {
|
|
306
|
-
channels = new Map();
|
|
307
|
-
live.set(connection.id, channels);
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
channels.set(channel, created);
|
|
309
|
+
channelsOf(connection.id).set(channel, created);
|
|
311
310
|
return created.session;
|
|
312
311
|
},
|
|
313
312
|
async disconnect(connectionId) {
|
package/src/client/types.ts
CHANGED
|
@@ -159,3 +159,6 @@ export class DatabaseRequestError extends Error {
|
|
|
159
159
|
this.change = change;
|
|
160
160
|
}
|
|
161
161
|
}
|
|
162
|
+
|
|
163
|
+
/* Whether a request failed because it was cancelled, which is no failure to show. */
|
|
164
|
+
export const isCancelled = (error: unknown): boolean => error instanceof DatabaseRequestError && error.code === 'cancelled';
|
|
@@ -43,6 +43,8 @@ interface FieldsProps<Config> {
|
|
|
43
43
|
touch(field: string): void;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
type Browse = ConnectionFormProps['onBrowse'];
|
|
47
|
+
|
|
46
48
|
/* A text field that is empty means "not set" for the optional fields of a config. */
|
|
47
49
|
const optionalText = (text: string): string | undefined => (text === '' ? undefined : text);
|
|
48
50
|
|
|
@@ -113,13 +115,7 @@ function useProblem(problem: ConfigProblem | undefined, field: string, touched:
|
|
|
113
115
|
return t(`connections.form.problems.${problem}`);
|
|
114
116
|
}
|
|
115
117
|
|
|
116
|
-
function SqliteFields({
|
|
117
|
-
config,
|
|
118
|
-
onConfigChange,
|
|
119
|
-
touched,
|
|
120
|
-
touch,
|
|
121
|
-
onBrowse
|
|
122
|
-
}: FieldsProps<SqliteConnectionConfig> & { onBrowse: ConnectionFormProps['onBrowse'] }) {
|
|
118
|
+
function SqliteFields({ config, onConfigChange, touched, touch, onBrowse }: FieldsProps<SqliteConnectionConfig> & { onBrowse: Browse }) {
|
|
123
119
|
const { t } = useTranslation('database');
|
|
124
120
|
const browsing = useAsyncAction();
|
|
125
121
|
const problem = configProblems(config).path;
|
|
@@ -144,22 +140,15 @@ function SqliteFields({
|
|
|
144
140
|
orientation="horizontal"
|
|
145
141
|
group={onBrowse !== undefined}
|
|
146
142
|
>
|
|
147
|
-
<
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
/>
|
|
157
|
-
{onBrowse !== undefined && (
|
|
158
|
-
<Button variant="secondary" disabled={browsing.busy} onClick={() => void browse()}>
|
|
159
|
-
{t('connections.form.browse')}
|
|
160
|
-
</Button>
|
|
161
|
-
)}
|
|
162
|
-
</div>
|
|
143
|
+
<PathInput
|
|
144
|
+
value={config.path}
|
|
145
|
+
placeholder="/path/to/database.sqlite"
|
|
146
|
+
label={t('connections.form.path')}
|
|
147
|
+
busy={browsing.busy}
|
|
148
|
+
onBlur={() => touch('path')}
|
|
149
|
+
onValueChange={(path) => onConfigChange({ ...config, path })}
|
|
150
|
+
onBrowse={onBrowse === undefined ? undefined : () => void browse()}
|
|
151
|
+
/>
|
|
163
152
|
</Field>
|
|
164
153
|
<Field label={t('connections.form.create')} hint={t('connections.form.createHint')} orientation="horizontal" group>
|
|
165
154
|
<Switch
|
|
@@ -172,6 +161,46 @@ function SqliteFields({
|
|
|
172
161
|
);
|
|
173
162
|
}
|
|
174
163
|
|
|
164
|
+
/* A path typed by hand, with a Browse button beside it when the app has a file dialog. */
|
|
165
|
+
function PathInput({
|
|
166
|
+
value,
|
|
167
|
+
placeholder,
|
|
168
|
+
label,
|
|
169
|
+
busy,
|
|
170
|
+
onBlur,
|
|
171
|
+
onValueChange,
|
|
172
|
+
onBrowse
|
|
173
|
+
}: {
|
|
174
|
+
value: string;
|
|
175
|
+
placeholder: string;
|
|
176
|
+
label: string;
|
|
177
|
+
busy: boolean;
|
|
178
|
+
onBlur?: () => void;
|
|
179
|
+
onValueChange(path: string): void;
|
|
180
|
+
onBrowse: (() => void) | undefined;
|
|
181
|
+
}) {
|
|
182
|
+
const { t } = useTranslation('database');
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<div className="flex items-center gap-2">
|
|
186
|
+
<Input
|
|
187
|
+
mono
|
|
188
|
+
value={value}
|
|
189
|
+
placeholder={placeholder}
|
|
190
|
+
spellCheck={false}
|
|
191
|
+
aria-label={onBrowse === undefined ? undefined : label}
|
|
192
|
+
onBlur={onBlur}
|
|
193
|
+
onChange={(e) => onValueChange(e.target.value)}
|
|
194
|
+
/>
|
|
195
|
+
{onBrowse !== undefined && (
|
|
196
|
+
<Button variant="secondary" disabled={busy} onClick={onBrowse}>
|
|
197
|
+
{t('connections.form.browse')}
|
|
198
|
+
</Button>
|
|
199
|
+
)}
|
|
200
|
+
</div>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
175
204
|
function PortField({
|
|
176
205
|
label,
|
|
177
206
|
hint,
|
|
@@ -205,7 +234,7 @@ function PortField({
|
|
|
205
234
|
);
|
|
206
235
|
}
|
|
207
236
|
|
|
208
|
-
function MysqlFields({ config, onConfigChange, touched, touch, onBrowse }: FieldsProps<MysqlConnectionConfig> & { onBrowse:
|
|
237
|
+
function MysqlFields({ config, onConfigChange, touched, touch, onBrowse }: FieldsProps<MysqlConnectionConfig> & { onBrowse: Browse }) {
|
|
209
238
|
const { t } = useTranslation('database');
|
|
210
239
|
const mode = modeOf(config);
|
|
211
240
|
const tunnel = config.tunnel;
|
|
@@ -316,14 +345,7 @@ function SocketField({ config, onConfigChange, touched, touch }: FieldsProps<Mys
|
|
|
316
345
|
);
|
|
317
346
|
}
|
|
318
347
|
|
|
319
|
-
function SshFields({
|
|
320
|
-
config,
|
|
321
|
-
tunnel,
|
|
322
|
-
onConfigChange,
|
|
323
|
-
touched,
|
|
324
|
-
touch,
|
|
325
|
-
onBrowse
|
|
326
|
-
}: FieldsProps<MysqlConnectionConfig> & { tunnel: SshTunnel; onBrowse: ConnectionFormProps['onBrowse'] }) {
|
|
348
|
+
function SshFields({ config, tunnel, onConfigChange, touched, touch, onBrowse }: FieldsProps<MysqlConnectionConfig> & { tunnel: SshTunnel; onBrowse: Browse }) {
|
|
327
349
|
const { t } = useTranslation('database');
|
|
328
350
|
const browsing = useAsyncAction();
|
|
329
351
|
const problems = configProblems(config);
|
|
@@ -369,21 +391,14 @@ function SshFields({
|
|
|
369
391
|
orientation="horizontal"
|
|
370
392
|
group={onBrowse !== undefined}
|
|
371
393
|
>
|
|
372
|
-
<
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
/>
|
|
381
|
-
{onBrowse !== undefined && (
|
|
382
|
-
<Button variant="secondary" disabled={browsing.busy} onClick={() => void browse()}>
|
|
383
|
-
{t('connections.form.browse')}
|
|
384
|
-
</Button>
|
|
385
|
-
)}
|
|
386
|
-
</div>
|
|
394
|
+
<PathInput
|
|
395
|
+
value={tunnel.identityFile ?? ''}
|
|
396
|
+
placeholder="~/.ssh/id_ed25519"
|
|
397
|
+
label={t('connections.form.ssh.identityFile')}
|
|
398
|
+
busy={browsing.busy}
|
|
399
|
+
onValueChange={(identityFile) => update({ identityFile: optionalText(identityFile) })}
|
|
400
|
+
onBrowse={onBrowse === undefined ? undefined : () => void browse()}
|
|
401
|
+
/>
|
|
387
402
|
</Field>
|
|
388
403
|
<ServerFields config={config} onConfigChange={onConfigChange} touched={touched} touch={touch} />
|
|
389
404
|
</>
|
|
@@ -60,16 +60,17 @@ export function ConnectionManager({ value, onValueChange, selected, onSelectedCh
|
|
|
60
60
|
onSelectedChange?.(id);
|
|
61
61
|
};
|
|
62
62
|
|
|
63
|
-
const
|
|
64
|
-
const connection: Connection = { id: crypto.randomUUID(), name: t('connections.newName'), config: defaultConfig(engine) };
|
|
63
|
+
const append = (connection: Connection): void => {
|
|
65
64
|
onValueChange([...value, connection]);
|
|
66
65
|
select(connection.id);
|
|
67
66
|
};
|
|
68
67
|
|
|
68
|
+
const add = (engine: Engine): void => {
|
|
69
|
+
append({ id: crypto.randomUUID(), name: t('connections.newName'), config: defaultConfig(engine) });
|
|
70
|
+
};
|
|
71
|
+
|
|
69
72
|
const addFromContainer = (container: DockerContainer): void => {
|
|
70
|
-
|
|
71
|
-
onValueChange([...value, connection]);
|
|
72
|
-
select(connection.id);
|
|
73
|
+
append(connectionFromContainer(crypto.randomUUID(), container));
|
|
73
74
|
};
|
|
74
75
|
|
|
75
76
|
const change = (next: Connection): void => {
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { Ref } from 'react';
|
|
2
|
-
|
|
3
2
|
import { Icon } from '@adecore/ui';
|
|
4
3
|
import type { Engine } from '../protocol/index.ts';
|
|
5
4
|
import { ENGINE_ICONS } from './engine-icons.ts';
|
|
@@ -12,7 +11,6 @@ export interface EngineIconProps {
|
|
|
12
11
|
ref?: Ref<SVGSVGElement>;
|
|
13
12
|
}
|
|
14
13
|
|
|
15
|
-
/* A file on disk for SQLite, a server for MySQL and MariaDB. */
|
|
16
14
|
export function EngineIcon({ engine, size = 16, className, ref }: EngineIconProps) {
|
|
17
15
|
return <Icon ref={ref} icon={ENGINE_ICONS[engine]} size={size} className={className} />;
|
|
18
16
|
}
|
|
@@ -4,7 +4,7 @@ import { Check, CircleAlert, History, Play, Square, X } from 'lucide-react';
|
|
|
4
4
|
import { useTranslation } from 'react-i18next';
|
|
5
5
|
import { Banner, Button, ColumnResizeHandle, Icon, IconButton, messageOf, PromptDialog, Spinner, Tabs, Tooltip, useColumnResize } from '@adecore/ui';
|
|
6
6
|
import { formatNumber } from '@adecore/ui/format';
|
|
7
|
-
import {
|
|
7
|
+
import { isCancelled, type Connection } from '../client/types.ts';
|
|
8
8
|
import type { DatabaseNotice } from '../actions.ts';
|
|
9
9
|
import { useDatabaseAction, useDatabaseClient, useDatabaseFiles, useDatabaseNotice, useDatabaseStorage } from '../client-context.ts';
|
|
10
10
|
import type { FileFormat, RowsResult, SchemaInfo, StatementResult } from '../protocol/index.ts';
|
|
@@ -84,6 +84,49 @@ function AppEditor({ render, ...editor }: QueryConsoleEditorProps & { render(edi
|
|
|
84
84
|
return render(editor);
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/* Run and Run all, or Cancel while a run is under way. */
|
|
88
|
+
function RunButtons({
|
|
89
|
+
running,
|
|
90
|
+
disabled,
|
|
91
|
+
onCancel,
|
|
92
|
+
onRun
|
|
93
|
+
}: {
|
|
94
|
+
running: boolean;
|
|
95
|
+
disabled: boolean;
|
|
96
|
+
onCancel(): void;
|
|
97
|
+
onRun(scope: QueryConsoleRunScope): void;
|
|
98
|
+
}) {
|
|
99
|
+
const { t } = useTranslation('database');
|
|
100
|
+
|
|
101
|
+
if (running) {
|
|
102
|
+
return (
|
|
103
|
+
<>
|
|
104
|
+
<Button size="sm" onClick={onCancel}>
|
|
105
|
+
<Icon icon={Square} size={12} />
|
|
106
|
+
{t('console.cancel')}
|
|
107
|
+
</Button>
|
|
108
|
+
<Spinner size={14} label={t('console.running')} />
|
|
109
|
+
</>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<>
|
|
115
|
+
<Tooltip label={t('console.run')} kbd={RUN_SHORTCUT}>
|
|
116
|
+
<Button variant="primary" size="sm" disabled={disabled} onClick={() => onRun('selection-or-statement')}>
|
|
117
|
+
<Icon icon={Play} size={12} />
|
|
118
|
+
{t('console.run')}
|
|
119
|
+
</Button>
|
|
120
|
+
</Tooltip>
|
|
121
|
+
<Tooltip label={t('console.runAll')} kbd={RUN_ALL_SHORTCUT}>
|
|
122
|
+
<Button variant="secondary" size="sm" disabled={disabled} onClick={() => onRun('all')}>
|
|
123
|
+
{t('console.runAll')}
|
|
124
|
+
</Button>
|
|
125
|
+
</Tooltip>
|
|
126
|
+
</>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
87
130
|
/* Type SQL, run it, and read what each statement did. Several statements give a tab each. */
|
|
88
131
|
export function QueryConsole({
|
|
89
132
|
connection,
|
|
@@ -139,7 +182,7 @@ export function QueryConsole({
|
|
|
139
182
|
const [exporting, setExporting] = useState(false);
|
|
140
183
|
// What the console shows itself when the app takes no notices.
|
|
141
184
|
const [notice, setNotice] = useState<DatabaseNotice | null>(null);
|
|
142
|
-
|
|
185
|
+
// Closed until the first run, so the editor has the whole height before anything ran.
|
|
143
186
|
const [resultsOpen, setResultsOpen] = useState(false);
|
|
144
187
|
const [resultsHeight, setResultsHeight] = useState(() => parseHeight(storage?.get(RESULTS_HEIGHT_KEY)));
|
|
145
188
|
const { startResize } = useColumnResize(resultsPane, {
|
|
@@ -250,7 +293,7 @@ export function QueryConsole({
|
|
|
250
293
|
setRun({ status: 'done', results: executed.results });
|
|
251
294
|
record(outcomeOf(executed.results));
|
|
252
295
|
} catch (error) {
|
|
253
|
-
if (controller.signal.aborted || (error
|
|
296
|
+
if (controller.signal.aborted || isCancelled(error)) {
|
|
254
297
|
setRun({ status: 'cancelled' });
|
|
255
298
|
} else {
|
|
256
299
|
setRun({ status: 'failed', message: messageOf(error) });
|
|
@@ -379,34 +422,12 @@ export function QueryConsole({
|
|
|
379
422
|
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-border px-3 py-2">
|
|
380
423
|
{session !== null && (
|
|
381
424
|
<>
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
<Spinner size={14} label={t('console.running')} />
|
|
389
|
-
</>
|
|
390
|
-
) : (
|
|
391
|
-
<>
|
|
392
|
-
<Tooltip label={t('console.run')} kbd={RUN_SHORTCUT}>
|
|
393
|
-
<Button
|
|
394
|
-
variant="primary"
|
|
395
|
-
size="sm"
|
|
396
|
-
disabled={blank || busy}
|
|
397
|
-
onClick={() => requestRun(targetOf('selection-or-statement'))}
|
|
398
|
-
>
|
|
399
|
-
<Icon icon={Play} size={12} />
|
|
400
|
-
{t('console.run')}
|
|
401
|
-
</Button>
|
|
402
|
-
</Tooltip>
|
|
403
|
-
<Tooltip label={t('console.runAll')} kbd={RUN_ALL_SHORTCUT}>
|
|
404
|
-
<Button variant="secondary" size="sm" disabled={blank || busy} onClick={() => requestRun(targetOf('all'))}>
|
|
405
|
-
{t('console.runAll')}
|
|
406
|
-
</Button>
|
|
407
|
-
</Tooltip>
|
|
408
|
-
</>
|
|
409
|
-
)}
|
|
425
|
+
<RunButtons
|
|
426
|
+
running={run.status === 'running'}
|
|
427
|
+
disabled={blank || busy}
|
|
428
|
+
onCancel={() => running.current?.abort()}
|
|
429
|
+
onRun={(scope) => requestRun(targetOf(scope))}
|
|
430
|
+
/>
|
|
410
431
|
<TransactionControls
|
|
411
432
|
mode={mode}
|
|
412
433
|
onModeChange={changeMode}
|
package/src/console/editing.ts
CHANGED
|
@@ -18,7 +18,6 @@ const lineEndOf = (value: string, at: number): number => {
|
|
|
18
18
|
return newline === -1 ? value.length : newline;
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
-
/* The whitespace a line begins with. */
|
|
22
21
|
const indentOf = (line: string): string => /^[ \t]*/.exec(line)![0];
|
|
23
22
|
|
|
24
23
|
/* The lines a selection touches. A selection that ends at the start of a line leaves that line out. */
|
|
@@ -58,13 +57,12 @@ export const outdentEdit = (value: string, start: number, end: number): TextEdit
|
|
|
58
57
|
if (total === 0) {
|
|
59
58
|
return null;
|
|
60
59
|
}
|
|
61
|
-
const firstLineStart = from;
|
|
62
60
|
return {
|
|
63
61
|
from,
|
|
64
62
|
to,
|
|
65
63
|
insert: lines.map((line, index) => line.slice(removed[index]!)).join('\n'),
|
|
66
|
-
selectionStart: Math.max(
|
|
67
|
-
selectionEnd: Math.max(
|
|
64
|
+
selectionStart: Math.max(from, start - removed[0]!),
|
|
65
|
+
selectionEnd: Math.max(from, end - total)
|
|
68
66
|
};
|
|
69
67
|
};
|
|
70
68
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react';
|
|
2
|
+
import type { DatabaseStorage } from '../actions.ts';
|
|
2
3
|
import { useDatabaseStorage } from '../client-context.ts';
|
|
3
4
|
import { addEntry, historyKey, parseHistory, serializeHistory, type HistoryEntry } from './history.ts';
|
|
4
5
|
|
|
@@ -15,14 +16,16 @@ export interface ConsoleHistory {
|
|
|
15
16
|
clear(): void;
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
const load = (storage: DatabaseStorage | undefined, key: string): Loaded => ({ key, entries: parseHistory(storage?.get(key) ?? null), changed: false });
|
|
20
|
+
|
|
18
21
|
/* The runs of one connection, kept in the app's storage when it has one and for the life of the console when it has not. */
|
|
19
22
|
export function useConsoleHistory(connectionId: string): ConsoleHistory {
|
|
20
23
|
const storage = useDatabaseStorage();
|
|
21
24
|
const key = historyKey(connectionId);
|
|
22
|
-
const [state, setState] = useState<Loaded>(() => (
|
|
25
|
+
const [state, setState] = useState<Loaded>(() => load(storage, key));
|
|
23
26
|
|
|
24
27
|
if (state.key !== key) {
|
|
25
|
-
setState(
|
|
28
|
+
setState(load(storage, key));
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
useEffect(() => {
|
package/src/ddl/alter-mysql.ts
CHANGED
|
@@ -1,19 +1,11 @@
|
|
|
1
1
|
import { columnSpecSql } from './column.ts';
|
|
2
|
-
import { foreignKeySql, mysqlOptionsSql } from './create.ts';
|
|
2
|
+
import { foreignKeySql, inlineIndexSql, mysqlOptionsSql } from './create.ts';
|
|
3
3
|
import { columnList, quote, tableName, type Dialect } from './dialect.ts';
|
|
4
4
|
import type { TableDiff } from './diff.ts';
|
|
5
|
-
import type { ColumnDraft,
|
|
5
|
+
import type { ColumnDraft, TableDraft } from './draft.ts';
|
|
6
6
|
|
|
7
7
|
const positionSql = (dialect: Dialect, after: string | null): string => (after === null ? 'FIRST' : `AFTER ${quote(dialect, after)}`);
|
|
8
8
|
|
|
9
|
-
const indexSql = (dialect: Dialect, index: IndexDraft): string =>
|
|
10
|
-
[
|
|
11
|
-
'ADD',
|
|
12
|
-
index.unique ? 'UNIQUE INDEX' : 'INDEX',
|
|
13
|
-
...(index.name === '' ? [] : [quote(dialect, index.name)]),
|
|
14
|
-
`(${columnList(dialect, index.columns)})`
|
|
15
|
-
].join(' ');
|
|
16
|
-
|
|
17
9
|
const withPosition = (clause: string, position: string | null): string => (position === null ? clause : `${clause} ${position}`);
|
|
18
10
|
|
|
19
11
|
/*
|
|
@@ -71,7 +63,7 @@ export const mysqlAlterSql = (dialect: Dialect, schema: string, original: TableD
|
|
|
71
63
|
late.push(`ADD PRIMARY KEY (${columnList(dialect, draft.primaryKey)})`);
|
|
72
64
|
}
|
|
73
65
|
for (const index of diff.addedIndexes) {
|
|
74
|
-
late.push(
|
|
66
|
+
late.push(`ADD ${inlineIndexSql(dialect, index)}`);
|
|
75
67
|
}
|
|
76
68
|
for (const foreignKey of diff.addedForeignKeys) {
|
|
77
69
|
late.push(`ADD ${foreignKeySql(dialect, schema, foreignKey)}`);
|
package/src/ddl/alter-sqlite.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { dropIndexSql } from './statements.ts';
|
|
|
8
8
|
const RENAME_COLUMN = [3, 25];
|
|
9
9
|
const DROP_COLUMN = [3, 35];
|
|
10
10
|
|
|
11
|
-
/*
|
|
11
|
+
/* Whether `ALTER TABLE .. ADD COLUMN` takes the column; it refuses a key, a stored computed value, or a NOT NULL column with nothing to fill the rows with. */
|
|
12
12
|
const addable = (column: ColumnDraft): boolean => {
|
|
13
13
|
if (column.autoIncrement || (column.generated && column.generatedClause !== null && /\bSTORED\b/i.test(column.generatedClause))) {
|
|
14
14
|
return false;
|
package/src/ddl/column.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { sqlLiteral } from '../sql.ts';
|
|
1
|
+
import { NUMERIC_TEXT, sqlLiteral } from '../sql.ts';
|
|
2
2
|
import { quote, type Dialect } from './dialect.ts';
|
|
3
3
|
import type { ColumnDraft } from './draft.ts';
|
|
4
4
|
|
|
5
|
-
const NUMBER = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/;
|
|
6
5
|
const STRING = /^'(?:[^']|'')*'$/;
|
|
7
6
|
const KEYWORD = /^(?:NULL|TRUE|FALSE|CURRENT_DATE|CURRENT_TIME(?:\(\d*\))?|CURRENT_TIMESTAMP(?:\(\d*\))?|LOCALTIME(?:STAMP)?(?:\(\d*\))?|NOW\(\d*\))$/i;
|
|
8
7
|
|
|
@@ -12,7 +11,7 @@ const KEYWORD = /^(?:NULL|TRUE|FALSE|CURRENT_DATE|CURRENT_TIME(?:\(\d*\))?|CURRE
|
|
|
12
11
|
*/
|
|
13
12
|
export const defaultClause = (expression: string): string => {
|
|
14
13
|
const text = expression.trim();
|
|
15
|
-
if (
|
|
14
|
+
if (NUMERIC_TEXT.test(text) || STRING.test(text) || KEYWORD.test(text)) {
|
|
16
15
|
return text;
|
|
17
16
|
}
|
|
18
17
|
return text.startsWith('(') && text.endsWith(')') ? text : `(${text})`;
|
|
@@ -37,11 +36,10 @@ export const columnSpecSql = (dialect: Dialect, column: ColumnDraft, { inlinePri
|
|
|
37
36
|
parts.push(column.autoIncrement ? 'PRIMARY KEY AUTOINCREMENT' : 'PRIMARY KEY');
|
|
38
37
|
}
|
|
39
38
|
// MariaDB takes neither NULL nor NOT NULL after the expression of a computed column.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
} else if (!column.nullable) {
|
|
39
|
+
const statesNullability = !(column.generated && dialect.flavor === 'mariadb');
|
|
40
|
+
if (statesNullability && !column.nullable) {
|
|
43
41
|
parts.push('NOT NULL');
|
|
44
|
-
} else if (mysql) {
|
|
42
|
+
} else if (statesNullability && mysql) {
|
|
45
43
|
// MySQL gives a TIMESTAMP column NOT NULL unless it is told otherwise.
|
|
46
44
|
parts.push('NULL');
|
|
47
45
|
}
|