@adecore/database 0.21.1 → 0.22.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.
Files changed (109) hide show
  1. package/dist/assign-ref.d.ts +2 -0
  2. package/dist/assign-ref.js +9 -0
  3. package/dist/client/client.js +14 -15
  4. package/dist/client/types.d.ts +1 -0
  5. package/dist/client/types.js +2 -0
  6. package/dist/connections/ConnectionForm.js +7 -2
  7. package/dist/connections/ConnectionManager.js +5 -5
  8. package/dist/connections/EngineIcon.js +0 -1
  9. package/dist/console/QueryConsole.js +12 -4
  10. package/dist/console/editing.js +2 -4
  11. package/dist/console/useConsoleHistory.js +3 -2
  12. package/dist/ddl/alter-mysql.js +2 -8
  13. package/dist/ddl/alter-sqlite.js +1 -1
  14. package/dist/ddl/column.js +5 -8
  15. package/dist/ddl/create.d.ts +1 -0
  16. package/dist/ddl/create.js +1 -1
  17. package/dist/ddl/diff.js +25 -23
  18. package/dist/designer/ColumnsEditor.js +8 -6
  19. package/dist/designer/ForeignKeysEditor.js +5 -1
  20. package/dist/designer/OptionsEditor.js +12 -3
  21. package/dist/designer/SqlPreview.d.ts +1 -5
  22. package/dist/designer/SqlPreview.js +1 -1
  23. package/dist/designer/TableDesigner.js +7 -6
  24. package/dist/explorer/DatabaseExplorer.js +12 -13
  25. package/dist/explorer/TableDialog.js +1 -1
  26. package/dist/explorer/tree.js +6 -16
  27. package/dist/grid/DataGrid.js +11 -14
  28. package/dist/grid/EnumPicker.js +4 -3
  29. package/dist/grid/GridHeaderCell.d.ts +1 -1
  30. package/dist/grid/GridHeaderCell.js +2 -2
  31. package/dist/grid/RecordView.js +2 -1
  32. package/dist/grid/ValueDock.js +2 -6
  33. package/dist/grid/aggregates.js +2 -3
  34. package/dist/grid/column-layout.js +3 -2
  35. package/dist/grid/copy-formats.js +2 -3
  36. package/dist/grid/display.d.ts +0 -1
  37. package/dist/grid/display.js +1 -3
  38. package/dist/grid/layout.js +6 -11
  39. package/dist/host/helper-path.js +8 -0
  40. package/dist/host/host.js +11 -16
  41. package/dist/host/validate.js +44 -43
  42. package/dist/sql.d.ts +2 -0
  43. package/dist/sql.js +5 -2
  44. package/dist/structure/StructureView.js +5 -6
  45. package/dist/table/TableView.js +19 -12
  46. package/dist/table/command-field.js +8 -12
  47. package/dist/table/grid-rows.js +2 -1
  48. package/dist/table/layout-store.js +5 -2
  49. package/dist/table/pending.js +11 -3
  50. package/dist/table/useTableTransfer.js +6 -5
  51. package/dist/testing/fake.js +7 -9
  52. package/dist/value/ValueEditor.js +1 -1
  53. package/dist/value/draft.js +1 -6
  54. package/dist/value/json.js +4 -4
  55. package/dist/workbench/DatabaseWorkbench.js +12 -5
  56. package/dist/workbench/tabs.js +3 -4
  57. package/package.json +6 -7
  58. package/src/assign-ref.ts +10 -0
  59. package/src/client/client.ts +17 -18
  60. package/src/client/types.ts +3 -0
  61. package/src/connections/ConnectionForm.tsx +62 -47
  62. package/src/connections/ConnectionManager.tsx +6 -5
  63. package/src/connections/EngineIcon.tsx +0 -2
  64. package/src/console/QueryConsole.tsx +52 -31
  65. package/src/console/editing.ts +2 -4
  66. package/src/console/useConsoleHistory.ts +5 -2
  67. package/src/ddl/alter-mysql.ts +3 -11
  68. package/src/ddl/alter-sqlite.ts +1 -1
  69. package/src/ddl/column.ts +5 -7
  70. package/src/ddl/create.ts +1 -1
  71. package/src/ddl/diff.ts +30 -25
  72. package/src/designer/ColumnsEditor.tsx +145 -125
  73. package/src/designer/ForeignKeysEditor.tsx +44 -27
  74. package/src/designer/OptionsEditor.tsx +95 -75
  75. package/src/designer/SqlPreview.tsx +2 -3
  76. package/src/designer/TableDesigner.tsx +7 -6
  77. package/src/explorer/DatabaseExplorer.tsx +26 -31
  78. package/src/explorer/TableDialog.tsx +1 -1
  79. package/src/explorer/tree.ts +8 -19
  80. package/src/grid/DataGrid.tsx +11 -14
  81. package/src/grid/EnumPicker.tsx +4 -3
  82. package/src/grid/GridHeaderCell.tsx +2 -3
  83. package/src/grid/RecordView.tsx +2 -1
  84. package/src/grid/ValueDock.tsx +2 -5
  85. package/src/grid/aggregates.ts +2 -4
  86. package/src/grid/column-layout.ts +3 -2
  87. package/src/grid/copy-formats.ts +2 -3
  88. package/src/grid/display.ts +1 -5
  89. package/src/grid/layout.ts +10 -11
  90. package/src/host/helper-path.ts +8 -8
  91. package/src/host/host.ts +11 -16
  92. package/src/host/validate.ts +45 -43
  93. package/src/protocol/messages.ts +0 -1
  94. package/src/sql.ts +7 -2
  95. package/src/structure/StructureView.tsx +7 -8
  96. package/src/table/CommandField.tsx +0 -1
  97. package/src/table/TableView.tsx +48 -34
  98. package/src/table/command-field.ts +9 -12
  99. package/src/table/grid-rows.ts +2 -7
  100. package/src/table/layout-store.ts +6 -3
  101. package/src/table/pending.ts +12 -7
  102. package/src/table/useTableTransfer.ts +7 -5
  103. package/src/testing/fake.ts +8 -12
  104. package/src/value/ValueEditor.tsx +1 -1
  105. package/src/value/draft.ts +1 -6
  106. package/src/value/json.ts +4 -4
  107. package/src/workbench/DatabaseWorkbench.tsx +14 -6
  108. package/src/workbench/DesignerTab.tsx +1 -1
  109. 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
- if ([...dirty].some((id) => !state.tabs.some((tab) => tab.id === id))) {
45
- setDirty(new Set([...dirty].filter((id) => state.tabs.some((tab) => tab.id === id))));
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(shortcut('Mod+W'), e.nativeEvent, isApplePlatform())) {
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 === 'table' ? Table : tab.kind === 'console' ? SquareTerminal : PencilRuler, 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: () => {
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
  }
@@ -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.kind === 'designer' && tab.connectionId === subject.connectionId && tab.schema === subject.schema && tab.table === table));
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.1",
3
+ "version": "0.22.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.21.1",
65
- "@adecore/database-linux-arm64": "0.21.1",
66
- "@adecore/database-linux-x64": "0.21.1",
67
- "@adecore/database-win32-x64": "0.21.1"
64
+ "@adecore/database-darwin-arm64": "0.22.0",
65
+ "@adecore/database-linux-arm64": "0.22.0",
66
+ "@adecore/database-linux-x64": "0.22.0",
67
+ "@adecore/database-win32-x64": "0.22.0"
68
68
  },
69
69
  "peerDependencies": {
70
- "@adecore/ui": "^0.21.1",
70
+ "@adecore/ui": "^0.22.0",
71
71
  "i18next": "^26.4.2",
72
72
  "react": "^19.3.0",
73
73
  "react-dom": "^19.3.0",
@@ -81,7 +81,6 @@
81
81
  "@types/react": "^19.3.0",
82
82
  "@types/react-dom": "^19.3.0",
83
83
  "i18next": "^26.4.2",
84
- "oxc-parser": "^0.151.0",
85
84
  "react": "^19.3.0",
86
85
  "react-dom": "^19.3.0",
87
86
  "react-i18next": "^17.0.15",
@@ -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
+ }
@@ -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 CHANGES_SHAPE = /^(?:create|alter|drop|rename|truncate)\b/i;
28
+ const SCHEMA_STATEMENT = /^(?:create|alter|drop|rename|truncate)\b/i;
29
29
 
30
- const changesShape = (sql: string): boolean => CHANGES_SHAPE.test(sql.replace(LEADING_COMMENTS, ''));
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
- let channels = live.get(connection.id);
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' && changesShape(item.sql))) {
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
- let channels = live.get(connection.id);
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) {
@@ -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
- <div className="flex items-center gap-2">
148
- <Input
149
- mono
150
- value={config.path}
151
- placeholder="/path/to/database.sqlite"
152
- spellCheck={false}
153
- aria-label={onBrowse === undefined ? undefined : t('connections.form.path')}
154
- onBlur={() => touch('path')}
155
- onChange={(e) => onConfigChange({ ...config, path: e.target.value })}
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: ConnectionFormProps['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
- <div className="flex items-center gap-2">
373
- <Input
374
- mono
375
- value={tunnel.identityFile ?? ''}
376
- placeholder="~/.ssh/id_ed25519"
377
- spellCheck={false}
378
- aria-label={onBrowse === undefined ? undefined : t('connections.form.ssh.identityFile')}
379
- onChange={(e) => update({ identityFile: optionalText(e.target.value) })}
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 add = (engine: Engine): void => {
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
- const connection = connectionFromContainer(crypto.randomUUID(), container);
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 { DatabaseRequestError, type Connection } from '../client/types.ts';
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
- /* Closed until the first run, so the editor has the whole height before anything ran. */
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 instanceof DatabaseRequestError && error.code === 'cancelled')) {
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
- {run.status === 'running' ? (
383
- <>
384
- <Button size="sm" onClick={() => running.current?.abort()}>
385
- <Icon icon={Square} size={12} />
386
- {t('console.cancel')}
387
- </Button>
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}
@@ -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(firstLineStart, start - removed[0]!),
67
- selectionEnd: Math.max(firstLineStart, end - total)
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>(() => ({ key, entries: parseHistory(storage?.get(key) ?? null), changed: false }));
25
+ const [state, setState] = useState<Loaded>(() => load(storage, key));
23
26
 
24
27
  if (state.key !== key) {
25
- setState({ key, entries: parseHistory(storage?.get(key) ?? null), changed: false });
28
+ setState(load(storage, key));
26
29
  }
27
30
 
28
31
  useEffect(() => {
@@ -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, IndexDraft, TableDraft } from './draft.ts';
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(indexSql(dialect, index));
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)}`);
@@ -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
- /* The clauses `ALTER TABLE .. ADD COLUMN` refuses: a key, a computed value, or a NOT NULL column with nothing to fill the rows with. */
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;