@adecore/database 0.38.0 → 0.40.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 (131) hide show
  1. package/dist/console/QueryConsole.js +2 -10
  2. package/dist/console/SchemaPicker.js +0 -1
  3. package/dist/console/StatementResultView.js +1 -1
  4. package/dist/ddl/diff.js +1 -1
  5. package/dist/explorer/tree.js +0 -1
  6. package/dist/export-formats.d.ts +2 -0
  7. package/dist/export-formats.js +1 -0
  8. package/dist/grid/CopyAsMenu.js +0 -1
  9. package/dist/grid/DataGrid.js +10 -10
  10. package/dist/grid/column-layout.d.ts +0 -1
  11. package/dist/grid/column-layout.js +1 -2
  12. package/dist/grid/column-view.js +3 -2
  13. package/dist/grid/display.js +1 -2
  14. package/dist/host/host.js +1 -2
  15. package/dist/host/validate.d.ts +3 -0
  16. package/dist/host/validate.js +18 -15
  17. package/dist/same-list.d.ts +1 -0
  18. package/dist/same-list.js +1 -0
  19. package/dist/stored-size.d.ts +1 -0
  20. package/dist/stored-size.js +10 -0
  21. package/dist/table/TableToolbar.js +1 -1
  22. package/dist/table/TableView.js +2 -10
  23. package/package.json +15 -15
  24. package/src/DatabaseProvider.tsx +2 -0
  25. package/src/KeyIcon.tsx +1 -0
  26. package/src/client/client.ts +13 -0
  27. package/src/client-context.ts +2 -0
  28. package/src/connections/ConnectionForm.tsx +11 -0
  29. package/src/connections/ConnectionManager.tsx +9 -0
  30. package/src/connections/connection-config.ts +21 -0
  31. package/src/connections/docker.ts +2 -0
  32. package/src/connections/use-docker-containers.ts +3 -0
  33. package/src/console/DestructiveDialog.tsx +1 -0
  34. package/src/console/QueryConsole.tsx +38 -10
  35. package/src/console/SchemaPicker.tsx +1 -1
  36. package/src/console/SqlEditor.tsx +10 -0
  37. package/src/console/StatementResultView.tsx +7 -2
  38. package/src/console/TransactionControls.tsx +1 -0
  39. package/src/console/destructive.ts +11 -0
  40. package/src/console/editing.ts +10 -0
  41. package/src/console/history.ts +8 -0
  42. package/src/console/labels.ts +1 -0
  43. package/src/console/outcome.ts +1 -0
  44. package/src/console/result-keys.ts +16 -0
  45. package/src/console/shortcuts.ts +2 -0
  46. package/src/ddl/alter-mysql.ts +16 -0
  47. package/src/ddl/alter-sqlite.ts +21 -0
  48. package/src/ddl/alter.ts +2 -0
  49. package/src/ddl/column.ts +11 -0
  50. package/src/ddl/create.ts +15 -0
  51. package/src/ddl/dialect.ts +4 -0
  52. package/src/ddl/diff.ts +22 -2
  53. package/src/ddl/draft.ts +1 -0
  54. package/src/ddl/parse.ts +31 -0
  55. package/src/ddl/types.ts +3 -0
  56. package/src/ddl/validate.ts +12 -0
  57. package/src/designer/ColumnsEditor.tsx +3 -0
  58. package/src/designer/DesignerView.tsx +2 -0
  59. package/src/designer/ForeignKeysEditor.tsx +4 -0
  60. package/src/designer/IndexesEditor.tsx +1 -0
  61. package/src/designer/NamePicker.tsx +2 -0
  62. package/src/designer/OptionsEditor.tsx +4 -0
  63. package/src/designer/SqlPreview.tsx +2 -0
  64. package/src/designer/TableDesigner.tsx +14 -0
  65. package/src/designer/edit.ts +13 -0
  66. package/src/designer/plan.ts +3 -0
  67. package/src/designer/recover.ts +1 -0
  68. package/src/designer/useReferenceColumns.ts +5 -0
  69. package/src/explorer/DatabaseExplorer.tsx +40 -0
  70. package/src/explorer/RowMenu.tsx +2 -0
  71. package/src/explorer/TableDialog.tsx +6 -0
  72. package/src/explorer/expansion.ts +8 -0
  73. package/src/explorer/tree.ts +73 -2
  74. package/src/explorer/use-explorer-loads.ts +15 -0
  75. package/src/explorer/use-stored-expansion.ts +4 -0
  76. package/src/export-formats.ts +3 -0
  77. package/src/grid/CellEditor.tsx +1 -0
  78. package/src/grid/CopyAsMenu.tsx +1 -1
  79. package/src/grid/DataGrid.tsx +93 -23
  80. package/src/grid/EnumPicker.tsx +6 -0
  81. package/src/grid/GridHeaderCell.tsx +11 -0
  82. package/src/grid/GridRowView.tsx +3 -0
  83. package/src/grid/RecordView.tsx +19 -0
  84. package/src/grid/ValueDock.tsx +2 -0
  85. package/src/grid/aggregates.ts +12 -0
  86. package/src/grid/column-layout.ts +12 -4
  87. package/src/grid/column-selection.ts +9 -0
  88. package/src/grid/column-view.ts +6 -2
  89. package/src/grid/copy-formats.ts +13 -0
  90. package/src/grid/display.ts +17 -3
  91. package/src/grid/edit-value.ts +11 -0
  92. package/src/grid/enum-type.ts +15 -0
  93. package/src/grid/focused-value.ts +4 -0
  94. package/src/grid/layout.ts +12 -0
  95. package/src/grid/navigation.ts +5 -0
  96. package/src/grid/row-selection.ts +7 -0
  97. package/src/grid/sort.ts +1 -0
  98. package/src/grid/whole-cells.ts +8 -0
  99. package/src/host/host.ts +16 -3
  100. package/src/host/spawn.ts +2 -0
  101. package/src/host/validate.ts +37 -15
  102. package/src/same-list.ts +2 -0
  103. package/src/sql-split.ts +28 -0
  104. package/src/sql.ts +20 -0
  105. package/src/stored-size.ts +10 -0
  106. package/src/structure/StructureView.tsx +18 -0
  107. package/src/table/CommandField.tsx +11 -0
  108. package/src/table/ImportDialog.tsx +4 -0
  109. package/src/table/TableStatusBar.tsx +1 -0
  110. package/src/table/TableToolbar.tsx +2 -2
  111. package/src/table/TableView.tsx +50 -10
  112. package/src/table/command-field.ts +40 -0
  113. package/src/table/grid-rows.ts +13 -0
  114. package/src/table/import-mapping.ts +5 -0
  115. package/src/table/layout-store.ts +7 -0
  116. package/src/table/paging.ts +1 -0
  117. package/src/table/pending.ts +18 -0
  118. package/src/table/references.ts +4 -0
  119. package/src/table/useLoaded.ts +3 -0
  120. package/src/table/useStoredLayout.ts +2 -0
  121. package/src/table/useTableTransfer.ts +17 -1
  122. package/src/testing/fake.ts +24 -0
  123. package/src/use-database-resources.ts +1 -0
  124. package/src/use-popup-press.ts +3 -0
  125. package/src/value/ValueEditor.tsx +6 -0
  126. package/src/value/draft.ts +7 -0
  127. package/src/value/hex.ts +8 -0
  128. package/src/value/json.ts +16 -0
  129. package/src/value/views.ts +14 -0
  130. package/src/workbench/DatabaseWorkbench.tsx +22 -0
  131. package/src/workbench/tabs.ts +34 -0
@@ -46,10 +46,12 @@ export function ConnectionManager({ value, onValueChange, selected, onSelectedCh
46
46
  const client = useDatabaseClient();
47
47
  const pickedId = selected === undefined ? own : selected;
48
48
  const current = value.find((connection) => connection.id === pickedId) ?? value[0] ?? null;
49
+
49
50
  // The answer of one connection is gone once another is picked, so coming back does not bring it back.
50
51
  if (outcome !== null && outcome.connectionId !== current?.id) {
51
52
  setOutcome(null);
52
53
  }
54
+
53
55
  // An answer to an older config is never shown for a newer one.
54
56
  const shown = outcome !== null && current !== null && outcome.config === current.config ? outcome : null;
55
57
 
@@ -57,6 +59,7 @@ export function ConnectionManager({ value, onValueChange, selected, onSelectedCh
57
59
  if (selected === undefined) {
58
60
  setOwn(id);
59
61
  }
62
+
60
63
  onSelectedChange?.(id);
61
64
  };
62
65
 
@@ -192,6 +195,7 @@ export function ConnectionManager({ value, onValueChange, selected, onSelectedCh
192
195
  function DockerMenuItems({ onPick }: { onPick(container: DockerContainer): void }) {
193
196
  const { t } = useTranslation('database');
194
197
  const { state, reload } = useDockerContainers();
198
+
195
199
  if (state.status === 'loading') {
196
200
  return (
197
201
  <Menu.Label className="flex items-center gap-2">
@@ -200,6 +204,7 @@ function DockerMenuItems({ onPick }: { onPick(container: DockerContainer): void
200
204
  </Menu.Label>
201
205
  );
202
206
  }
207
+
203
208
  if (state.status !== 'ready') {
204
209
  return (
205
210
  <>
@@ -212,10 +217,13 @@ function DockerMenuItems({ onPick }: { onPick(container: DockerContainer): void
212
217
  </>
213
218
  );
214
219
  }
220
+
215
221
  const containers = state.containers.filter(isMysqlContainer);
222
+
216
223
  if (containers.length === 0) {
217
224
  return <Menu.Label>{t('connections.fromDocker.empty')}</Menu.Label>;
218
225
  }
226
+
219
227
  return containers.map((container) => (
220
228
  <Menu.Item key={container.id} onClick={() => onPick(container)}>
221
229
  <span className="flex min-w-0 flex-col">
@@ -235,6 +243,7 @@ function ConnectionTest({ connection, onOutcome }: { connection: Connection; onO
235
243
  const test = async (): Promise<void> => {
236
244
  const config = connection.config;
237
245
  setBusy(true);
246
+
238
247
  try {
239
248
  onOutcome({ connectionId: connection.id, config, info: await client.test(config) });
240
249
  } catch (e) {
@@ -34,9 +34,11 @@ export const isAbsolutePath = (path: string): boolean => /^(?:\/|[A-Za-z]:[\\/]|
34
34
 
35
35
  const sqliteProblems = (config: SqliteConnectionConfig): ConfigProblems => {
36
36
  const path = config.path.trim();
37
+
37
38
  if (path === '') {
38
39
  return { path: 'path-required' };
39
40
  }
41
+
40
42
  return isAbsolutePath(path) ? {} : { path: 'path-relative' };
41
43
  };
42
44
 
@@ -47,6 +49,7 @@ export const modeOf = (config: MysqlConnectionConfig): ConnectMode => {
47
49
  if (config.tunnel !== undefined) {
48
50
  return config.tunnel.kind;
49
51
  }
52
+
50
53
  return config.socket === undefined ? 'tcp' : 'socket';
51
54
  };
52
55
 
@@ -54,31 +57,39 @@ const mysqlProblems = (config: MysqlConnectionConfig): ConfigProblems => {
54
57
  const problems: { -readonly [K in keyof ConfigProblems]: ConfigProblems[K] } = {};
55
58
  const tunnel = config.tunnel;
56
59
  const mode = modeOf(config);
60
+
57
61
  if (mode === 'socket' && (config.socket ?? '').trim() === '') {
58
62
  problems.socket = 'socket-required';
59
63
  }
64
+
60
65
  if ((mode === 'tcp' || mode === 'ssh') && config.host.trim() === '') {
61
66
  problems.host = 'host-required';
62
67
  }
68
+
63
69
  if ((mode === 'tcp' || mode === 'ssh') && config.port !== undefined && !isPort(config.port)) {
64
70
  problems.port = 'port-range';
65
71
  }
72
+
66
73
  if (tunnel?.kind === 'ssh') {
67
74
  if (tunnel.host.trim() === '') {
68
75
  problems.sshHost = 'ssh-host-required';
69
76
  }
77
+
70
78
  if (tunnel.port !== undefined && !isPort(tunnel.port)) {
71
79
  problems.sshPort = 'ssh-port-range';
72
80
  }
73
81
  }
82
+
74
83
  if (tunnel?.kind === 'docker') {
75
84
  if (tunnel.container.trim() === '') {
76
85
  problems.container = 'container-required';
77
86
  }
87
+
78
88
  if (tunnel.port !== undefined && !isPort(tunnel.port)) {
79
89
  problems.containerPort = 'container-port-range';
80
90
  }
81
91
  }
92
+
82
93
  return problems;
83
94
  };
84
95
 
@@ -90,30 +101,38 @@ export const isValidConfig = (config: ConnectionConfig): boolean => Object.keys(
90
101
 
91
102
  const lastSegment = (path: string): string => {
92
103
  const segments = path.split(/[\\/]/).filter((segment) => segment !== '');
104
+
93
105
  return segments[segments.length - 1] ?? path;
94
106
  };
95
107
 
96
108
  const targetWhere = (config: MysqlConnectionConfig): string => {
97
109
  const tunnel = config.tunnel;
110
+
98
111
  if (tunnel?.kind === 'docker') {
99
112
  return tunnel.container === '' ? 'Docker' : `${tunnel.container} (Docker)`;
100
113
  }
114
+
101
115
  if (tunnel?.kind === 'ssh') {
102
116
  return tunnel.host === '' ? config.host : `${config.host} via ${tunnel.host}`;
103
117
  }
118
+
104
119
  if (config.socket !== undefined) {
105
120
  return lastSegment(config.socket);
106
121
  }
122
+
107
123
  return `${config.host}:${config.port ?? DEFAULT_MYSQL_PORT}`;
108
124
  };
109
125
 
110
126
  /* The short line under a connection's name: the file name of a database file, or `user@host:port`, `user@host via <ssh host>` and `user@container (Docker)`. */
111
127
  export const targetOf = (connection: Connection): string => {
112
128
  const config = connection.config;
129
+
113
130
  if (config.engine === 'sqlite') {
114
131
  return lastSegment(config.path);
115
132
  }
133
+
116
134
  const where = targetWhere(config);
135
+
117
136
  return config.user === '' || where === '' ? where : `${config.user}@${where}`;
118
137
  };
119
138
 
@@ -122,12 +141,14 @@ export const withMode = (config: MysqlConnectionConfig, mode: ConnectMode): Mysq
122
141
  if (modeOf(config) === mode) {
123
142
  return config;
124
143
  }
144
+
125
145
  const base: MysqlConnectionConfig = {
126
146
  ...config,
127
147
  host: config.host.trim() === '' ? DEFAULT_HOST : config.host,
128
148
  socket: undefined,
129
149
  tunnel: undefined
130
150
  };
151
+
131
152
  switch (mode) {
132
153
  case 'tcp':
133
154
  return base;
@@ -15,6 +15,7 @@ export const containerTitle = (container: DockerContainer): string => composeNam
15
15
  /* The port to connect to inside the container: MySQL's own when the container lists it or lists nothing, else the first it lists. */
16
16
  export const containerPortOf = (container: DockerContainer): number => {
17
17
  const ports = container.ports.map((entry) => entry.container);
18
+
18
19
  return ports.length === 0 || ports.includes(DEFAULT_MYSQL_PORT) ? DEFAULT_MYSQL_PORT : ports[0]!;
19
20
  };
20
21
 
@@ -28,6 +29,7 @@ const isEmpty = (text: string | undefined): boolean => text === undefined || tex
28
29
  export const withContainer = (config: MysqlConnectionConfig, container: DockerContainer): MysqlConnectionConfig => {
29
30
  const docker = withMode(config, 'docker');
30
31
  const { user, password, database } = container.suggested;
32
+
31
33
  return {
32
34
  ...docker,
33
35
  user: isEmpty(docker.user) && user !== undefined ? user : docker.user,
@@ -19,16 +19,19 @@ export function useDockerContainers(): { readonly state: DockerState; reload():
19
19
 
20
20
  useEffect(() => {
21
21
  const controller = new AbortController();
22
+
22
23
  client.discover('docker', { signal: controller.signal }).then(
23
24
  (containers) => setState({ status: 'ready', containers }),
24
25
  (e: unknown) => {
25
26
  if (controller.signal.aborted) {
26
27
  return;
27
28
  }
29
+
28
30
  const message = messageOf(e);
29
31
  setState({ status: e instanceof DatabaseRequestError && e.code === 'unsupported' ? 'unsupported' : 'error', message });
30
32
  }
31
33
  );
34
+
32
35
  return () => controller.abort();
33
36
  }, [client, attempt]);
34
37
 
@@ -15,6 +15,7 @@ export interface DestructiveDialogProps {
15
15
  /* Lists the statements that cannot be taken back and asks before they run. */
16
16
  export function DestructiveDialog({ statements, onConfirm, onCancel, ref }: DestructiveDialogProps) {
17
17
  const { t } = useTranslation('database');
18
+
18
19
  return (
19
20
  <PromptDialog
20
21
  ref={ref}
@@ -9,6 +9,7 @@ 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';
11
11
  import { splitStatements, statementAt } from '../sql-split.ts';
12
+ import { parseStoredSize } from '../stored-size.ts';
12
13
  import { DestructiveDialog } from './DestructiveDialog.tsx';
13
14
  import type { QueryConsoleEditorHandle, QueryConsoleEditorProps, QueryConsoleRunScope } from './editor-slot.ts';
14
15
  import { findDestructive, type DestructiveStatement } from './destructive.ts';
@@ -72,15 +73,6 @@ export const RESULTS_HEIGHT_KEY = 'database:console-results';
72
73
  const MIN_RESULTS_HEIGHT = 96;
73
74
  const MIN_EDITOR_HEIGHT = 120;
74
75
 
75
- const parseHeight = (raw: string | null | undefined): number | null => {
76
- try {
77
- const value: unknown = JSON.parse(raw ?? 'null');
78
- return typeof value === 'number' && Number.isFinite(value) && value >= MIN_RESULTS_HEIGHT ? Math.round(value) : null;
79
- } catch {
80
- return null;
81
- }
82
- };
83
-
84
76
  /* The app's editor as an element of its own, so it gets its props the way `SqlEditor` does and not during the console's render. */
85
77
  function AppEditor({ render, ...editor }: QueryConsoleEditorProps & { render(editor: QueryConsoleEditorProps): ReactNode }) {
86
78
  return render(editor);
@@ -154,12 +146,14 @@ export function QueryConsole({
154
146
  const channel = `console:${useId()}`;
155
147
  const session = useMemo(() => (connection === undefined ? null : client.session(connection, channel)), [client, connection, channel]);
156
148
  const engine = connection?.config.engine;
149
+
157
150
  useEffect(
158
151
  () => () => {
159
152
  void session?.close();
160
153
  },
161
154
  [session]
162
155
  );
156
+
163
157
  // Nothing is recorded without a connection, so the key of none is never written.
164
158
  const history = useConsoleHistory(connection?.id ?? '');
165
159
  const running = useRef<AbortController | null>(null);
@@ -186,7 +180,8 @@ export function QueryConsole({
186
180
  const [notice, setNotice] = useState<DatabaseNotice | null>(null);
187
181
  // Closed until the first run, so the editor has the whole height before anything ran.
188
182
  const [resultsOpen, setResultsOpen] = useState(false);
189
- const [resultsHeight, setResultsHeight] = useState(() => parseHeight(storage?.get(RESULTS_HEIGHT_KEY)));
183
+ const [resultsHeight, setResultsHeight] = useState(() => parseStoredSize(storage?.get(RESULTS_HEIGHT_KEY), MIN_RESULTS_HEIGHT));
184
+
190
185
  const { startResize } = useColumnResize(resultsPane, {
191
186
  size: resultsHeight ?? MIN_RESULTS_HEIGHT,
192
187
  min: MIN_RESULTS_HEIGHT,
@@ -194,6 +189,7 @@ export function QueryConsole({
194
189
  max: () => (resultsPane.current?.parentElement?.clientHeight ?? 0) - MIN_EDITOR_HEIGHT,
195
190
  onSize: setResultsHeight
196
191
  });
192
+
197
193
  const sql = value ?? own;
198
194
  const blank = sql.trim() === '';
199
195
  const busy = run.status === 'running' || settling;
@@ -216,11 +212,14 @@ export function QueryConsole({
216
212
  if (session === null || engine !== 'mysql') {
217
213
  return undefined;
218
214
  }
215
+
219
216
  const controller = new AbortController();
217
+
220
218
  session
221
219
  .schemas({ signal: controller.signal })
222
220
  .then((list) => setSchemas(orderSchemas(list)))
223
221
  .catch(() => undefined);
222
+
224
223
  return () => controller.abort();
225
224
  }, [engine, session]);
226
225
 
@@ -236,6 +235,7 @@ export function QueryConsole({
236
235
  if (value === undefined) {
237
236
  setOwn(next);
238
237
  }
238
+
239
239
  onValueChange?.(next);
240
240
  };
241
241
 
@@ -244,15 +244,20 @@ export function QueryConsole({
244
244
  if (engine === undefined) {
245
245
  return null;
246
246
  }
247
+
247
248
  const selection = editorHandle.current?.selection();
249
+
248
250
  if (scope === 'all' || selection === undefined) {
249
251
  return splitStatements(sql, engine).length === 0 ? null : sql;
250
252
  }
253
+
251
254
  const { start, end } = selection;
252
255
  const selected = sql.slice(start, end);
256
+
253
257
  if (splitStatements(selected, engine).length > 0) {
254
258
  return selected;
255
259
  }
260
+
256
261
  return statementAt(sql, start, engine)?.text ?? null;
257
262
  };
258
263
 
@@ -260,11 +265,15 @@ export function QueryConsole({
260
265
  if (target === null || busy || engine === undefined) {
261
266
  return;
262
267
  }
268
+
263
269
  const statements = findDestructive(target, engine);
270
+
264
271
  if (statements.length > 0) {
265
272
  setConfirming({ target, statements });
273
+
266
274
  return;
267
275
  }
276
+
268
277
  void execute(target);
269
278
  };
270
279
 
@@ -272,18 +281,22 @@ export function QueryConsole({
272
281
  if (session === null || connection === undefined) {
273
282
  return;
274
283
  }
284
+
275
285
  const controller = new AbortController();
276
286
  const mine = ++generation.current;
277
287
  running.current = controller;
278
288
  setResultsOpen(true);
279
289
  setRun({ status: 'running' });
280
290
  setNotice(null);
291
+
281
292
  const record = (outcome: Pick<HistoryEntry, 'ok' | 'rows'>): void =>
282
293
  history.record({ sql: target, at: Date.now(), connection: connection.name, ...outcome });
294
+
283
295
  try {
284
296
  if (mode === 'manual' && !inTransaction) {
285
297
  setInTransaction(await session.transaction('begin', { signal: controller.signal }));
286
298
  }
299
+
287
300
  // A cancel that stopped the run midway still answers what ran and whether a transaction stayed open.
288
301
  const executed = await session.execute(target, { schema: activeSchema, limit: PAGE_SIZE, signal: controller.signal });
289
302
  setInTransaction(executed.inTransaction);
@@ -307,6 +320,7 @@ export function QueryConsole({
307
320
 
308
321
  const pickHistory = (entry: HistoryEntry, runIt: boolean): void => {
309
322
  changeSql(entry.sql);
323
+
310
324
  if (runIt) {
311
325
  requestRun(entry.sql);
312
326
  }
@@ -316,9 +330,12 @@ export function QueryConsole({
316
330
  if (session === null) {
317
331
  return false;
318
332
  }
333
+
319
334
  setSettling(true);
335
+
320
336
  try {
321
337
  setInTransaction(await session.transaction(action));
338
+
322
339
  return true;
323
340
  } catch (error) {
324
341
  notify({
@@ -326,6 +343,7 @@ export function QueryConsole({
326
343
  title: t(action === 'commit' ? 'console.transaction.commitFailed' : 'console.transaction.rollbackFailed'),
327
344
  description: messageOf(error)
328
345
  });
346
+
329
347
  return false;
330
348
  } finally {
331
349
  setSettling(false);
@@ -336,6 +354,7 @@ export function QueryConsole({
336
354
  if (await settle(action)) {
337
355
  setMode('auto');
338
356
  }
357
+
339
358
  setEndingManual(false);
340
359
  };
341
360
 
@@ -351,11 +370,14 @@ export function QueryConsole({
351
370
  if (session === null) {
352
371
  return;
353
372
  }
373
+
354
374
  const mine = generation.current;
355
375
  setPaging(true);
356
376
  setNotice(null);
377
+
357
378
  try {
358
379
  const rows = await session.page(result.sql, { schema: activeSchema, offset, limit: PAGE_SIZE });
380
+
359
381
  if (generation.current === mine) {
360
382
  setPages((current) => ({ ...current, [index]: { offset, rows, before } }));
361
383
  }
@@ -370,9 +392,12 @@ export function QueryConsole({
370
392
  if (files === undefined || session === null) {
371
393
  return;
372
394
  }
395
+
373
396
  setExporting(true);
397
+
374
398
  try {
375
399
  const path = await files.save({ suggestedName: `result.${format}`, format });
400
+
376
401
  if (path !== null) {
377
402
  const exported = await session.export({ source: { kind: 'query', sql: result.sql, schema: activeSchema }, format, path });
378
403
  notify({ tone: 'success', title: t('console.exported', { count: exported.rows, formatted: formatNumber(exported.rows) }) });
@@ -391,6 +416,7 @@ export function QueryConsole({
391
416
  const shown: StatementResult | undefined = base?.kind === 'rows' && page !== undefined ? { kind: 'rows', sql: base.sql, ...page.rows } : base;
392
417
  const offset = page?.offset ?? 0;
393
418
  const before = page?.before ?? [];
419
+
394
420
  const pager: ResultPager | undefined =
395
421
  base?.kind === 'rows' && shown?.kind === 'rows' && engine !== undefined && isPageable(base.sql, engine) && (shown.hasMore || offset > 0)
396
422
  ? {
@@ -400,6 +426,7 @@ export function QueryConsole({
400
426
  onNext: () => void loadPage(index, base, offset + shown.rows.length, [...before, offset])
401
427
  }
402
428
  : undefined;
429
+
403
430
  const exportBase = files !== undefined && base?.kind === 'rows' ? base : undefined;
404
431
  // Read for the statement, not for each page of it, so paging through a result asks for nothing again.
405
432
  const keys = useResultKeys(session, engine, base);
@@ -538,6 +565,7 @@ export function QueryConsole({
538
565
  onConfirm={() => {
539
566
  const target = confirming?.target;
540
567
  setConfirming(null);
568
+
541
569
  if (target !== undefined) {
542
570
  void execute(target);
543
571
  }
@@ -13,9 +13,9 @@ export interface SchemaPickerProps {
13
13
  ref?: Ref<HTMLButtonElement>;
14
14
  }
15
15
 
16
- /* Picks the schema the console's statements run in. */
17
16
  export function SchemaPicker({ schemas, value, onValueChange, disabled, className, ref }: SchemaPickerProps) {
18
17
  const { t } = useTranslation('database');
18
+
19
19
  return (
20
20
  <Select
21
21
  ref={ref}
@@ -26,6 +26,7 @@ export function SqlEditor({ ref, value, onValueChange, run, label, placeholder,
26
26
 
27
27
  useEffect(() => {
28
28
  const pending = pendingSelection.current;
29
+
29
30
  if (pending !== null) {
30
31
  pendingSelection.current = null;
31
32
  pending.area.setSelectionRange(pending.start, pending.end);
@@ -37,29 +38,38 @@ export function SqlEditor({ ref, value, onValueChange, run, label, placeholder,
37
38
  const before = target.value;
38
39
  target.setSelectionRange(edit.from, edit.to);
39
40
  const inserted = document.execCommand('insertText', false, edit.insert);
41
+
40
42
  if (inserted && target.value === applyEdit(before, edit)) {
41
43
  target.setSelectionRange(edit.selectionStart, edit.selectionEnd);
44
+
42
45
  return;
43
46
  }
47
+
44
48
  pendingSelection.current = { area: target, start: edit.selectionStart, end: edit.selectionEnd };
45
49
  onValueChange(applyEdit(before, edit));
46
50
  };
47
51
 
48
52
  const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>): void => {
49
53
  const scope = runScopeOf(event, isApplePlatform());
54
+
50
55
  if (scope !== null) {
51
56
  event.preventDefault();
52
57
  run(scope);
58
+
53
59
  return;
54
60
  }
61
+
55
62
  if (event.nativeEvent.isComposing || event.metaKey || event.ctrlKey || event.altKey) {
56
63
  return;
57
64
  }
65
+
58
66
  const target = event.currentTarget;
59
67
  const { selectionStart, selectionEnd } = target;
68
+
60
69
  if (event.key === 'Tab') {
61
70
  event.preventDefault();
62
71
  const edit = event.shiftKey ? outdentEdit(target.value, selectionStart, selectionEnd) : indentEdit(target.value, selectionStart, selectionEnd);
72
+
63
73
  if (edit !== null) {
64
74
  apply(target, edit);
65
75
  }
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
4
4
  import { Banner, ButtonGroup, ContextMenu, Icon, IconButton, Menu, Spinner } from '@adecore/ui';
5
5
  import { formatDecimal, formatNumber } from '@adecore/ui/format';
6
6
  import type { DatabaseNotice } from '../actions.ts';
7
+ import { EXPORT_FORMATS } from '../export-formats.ts';
7
8
  import { DataGrid } from '../grid/DataGrid.tsx';
8
9
  import { previewValueOf } from '../grid/focused-value.ts';
9
10
  import type { FocusedCell, GridMenuContext, GridRow } from '../grid/types.ts';
@@ -17,8 +18,6 @@ import { ValuePanel } from '../value/ValuePanel.tsx';
17
18
  /* What "Copy as SQL INSERT" names the table of a result that comes from no table. */
18
19
  const RESULT_TABLE = 'result';
19
20
 
20
- const EXPORT_FORMATS: readonly FileFormat[] = ['csv', 'tsv', 'json', 'sql'];
21
-
22
21
  /* Paging through the rows of a statement that reads, one page at a time. */
23
22
  export interface ResultPager {
24
23
  /* Counted from 1. */
@@ -66,10 +65,12 @@ export function StatementResultView({
66
65
  }: StatementResultViewProps) {
67
66
  const { t } = useTranslation('database');
68
67
  const [focus, setFocus] = useState<FocusedCell | null>(null);
68
+
69
69
  const rows = useMemo(
70
70
  () => (result.kind === 'rows' ? result.rows.map((cells, index): GridRow => ({ key: `row:${index}`, number: offset + index + 1, cells })) : []),
71
71
  [result, offset]
72
72
  );
73
+
73
74
  const sqlTarget = useMemo((): SqlTarget | undefined => (engine === undefined ? undefined : { engine, table: RESULT_TABLE }), [engine]);
74
75
  const elapsed = t('console.elapsed', { value: formatDecimal(result.elapsedMs) });
75
76
 
@@ -115,17 +116,21 @@ export function StatementResultView({
115
116
 
116
117
  const referenceAt = (cell: FocusedCell): Reference | null => {
117
118
  const row = rows.find((candidate) => candidate.key === cell.rowKey);
119
+
118
120
  return keys === null || row === undefined ? null : keys.referenceAt(cell.column, row.cells);
119
121
  };
122
+
120
123
  const follow =
121
124
  keys === null || onFollowReference === undefined
122
125
  ? undefined
123
126
  : (cell: FocusedCell): void => {
124
127
  const reference = referenceAt(cell);
128
+
125
129
  if (reference !== null) {
126
130
  onFollowReference(reference);
127
131
  }
128
132
  };
133
+
129
134
  const referenceMenu = (context: GridMenuContext): ReactNode =>
130
135
  follow === undefined || context.cell === null || keys?.isReference(context.cell.column) !== true ? null : (
131
136
  <ContextMenu.Item disabled={referenceAt(context.cell) === null} onClick={() => context.cell !== null && follow(context.cell)}>
@@ -21,6 +21,7 @@ export interface TransactionControlsProps {
21
21
  /* Auto-commit on or off, and while a transaction is open, the way to end it. */
22
22
  export function TransactionControls({ mode, onModeChange, open, busy, onCommit, onRollback, className, ref }: TransactionControlsProps) {
23
23
  const { t } = useTranslation('database');
24
+
24
25
  return (
25
26
  <div ref={ref} className={clsx('flex items-center gap-2', className)}>
26
27
  <Tooltip label={t('console.transaction.hint')}>
@@ -18,8 +18,10 @@ interface Word {
18
18
  const wordsOf = (sql: string, engine: Engine): Word[] => {
19
19
  const words: Word[] = [];
20
20
  let depth = 0;
21
+
21
22
  for (const token of tokenize(sql, engine)) {
22
23
  const text = sql.slice(token.start, token.end);
24
+
23
25
  if (token.kind === 'word') {
24
26
  words.push({ word: text.toUpperCase(), depth });
25
27
  } else if (token.kind === 'punct' && text === '(') {
@@ -28,27 +30,35 @@ const wordsOf = (sql: string, engine: Engine): Word[] => {
28
30
  depth = Math.max(0, depth - 1);
29
31
  }
30
32
  }
33
+
31
34
  return words;
32
35
  };
33
36
 
34
37
  const kindOf = (sql: string, engine: Engine): DestructiveKind | null => {
35
38
  const words = wordsOf(sql, engine);
36
39
  const first = words[0]?.word;
40
+
37
41
  if (first === 'DROP') {
38
42
  return 'drop';
39
43
  }
44
+
40
45
  if (first === 'TRUNCATE') {
41
46
  return 'truncate';
42
47
  }
48
+
43
49
  if (first === 'ALTER') {
44
50
  return words.some(({ word }) => word === 'DROP') ? 'alterDrop' : null;
45
51
  }
52
+
46
53
  // A statement behind a `WITH` is the first DELETE or UPDATE outside any parentheses.
47
54
  const writer = first === 'WITH' ? words.find(({ word, depth }) => depth === 0 && (word === 'DELETE' || word === 'UPDATE')) : words[0];
55
+
48
56
  if (writer?.word !== 'DELETE' && writer?.word !== 'UPDATE') {
49
57
  return null;
50
58
  }
59
+
51
60
  const filtered = words.some(({ word, depth }) => word === 'WHERE' && depth === 0);
61
+
52
62
  return filtered ? null : writer.word === 'DELETE' ? 'delete' : 'update';
53
63
  };
54
64
 
@@ -56,5 +66,6 @@ const kindOf = (sql: string, engine: Engine): DestructiveKind | null => {
56
66
  export const findDestructive = (sql: string, engine: Engine): DestructiveStatement[] =>
57
67
  splitStatements(sql, engine).flatMap((statement) => {
58
68
  const kind = kindOf(statement.text, engine);
69
+
59
70
  return kind === null ? [] : [{ sql: statement.text, kind }];
60
71
  });
@@ -15,6 +15,7 @@ const lineStartOf = (value: string, at: number): number => value.lastIndexOf('\n
15
15
 
16
16
  const lineEndOf = (value: string, at: number): number => {
17
17
  const newline = value.indexOf('\n', at);
18
+
18
19
  return newline === -1 ? value.length : newline;
19
20
  };
20
21
 
@@ -24,6 +25,7 @@ const indentOf = (line: string): string => /^[ \t]*/.exec(line)![0];
24
25
  const touchedLines = (value: string, start: number, end: number): { from: number; to: number } => {
25
26
  const from = lineStartOf(value, start);
26
27
  const last = end > start && value[end - 1] === '\n' ? end - 1 : end;
28
+
27
29
  return { from, to: lineEndOf(value, last) };
28
30
  };
29
31
 
@@ -32,8 +34,10 @@ export const indentEdit = (value: string, start: number, end: number): TextEdit
32
34
  if (!value.slice(start, end).includes('\n')) {
33
35
  return { from: start, to: end, insert: INDENT, selectionStart: start + INDENT.length, selectionEnd: start + INDENT.length };
34
36
  }
37
+
35
38
  const { from, to } = touchedLines(value, start, end);
36
39
  const lines = value.slice(from, to).split('\n');
40
+
37
41
  return {
38
42
  from,
39
43
  to,
@@ -47,16 +51,21 @@ export const indentEdit = (value: string, start: number, end: number): TextEdit
47
51
  export const outdentEdit = (value: string, start: number, end: number): TextEdit | null => {
48
52
  const { from, to } = touchedLines(value, start, end);
49
53
  const lines = value.slice(from, to).split('\n');
54
+
50
55
  const removed = lines.map((line) => {
51
56
  if (line.startsWith('\t')) {
52
57
  return 1;
53
58
  }
59
+
54
60
  return Math.min(INDENT.length, /^ */.exec(line)![0].length);
55
61
  });
62
+
56
63
  const total = removed.reduce((sum, count) => sum + count, 0);
64
+
57
65
  if (total === 0) {
58
66
  return null;
59
67
  }
68
+
60
69
  return {
61
70
  from,
62
71
  to,
@@ -71,5 +80,6 @@ export const newlineEdit = (value: string, start: number, end: number): TextEdit
71
80
  const lineStart = lineStartOf(value, start);
72
81
  const indent = indentOf(value.slice(lineStart, start));
73
82
  const insert = `\n${indent}`;
83
+
74
84
  return { from: start, to: end, insert, selectionStart: start + insert.length, selectionEnd: start + insert.length };
75
85
  };