@adecore/database 0.21.1 → 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.
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 -6
  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
@@ -11,91 +11,111 @@ export function OptionsEditor({ draft, dialect, disabled, onChange, className, r
11
11
  const { t } = useTranslation('database');
12
12
  const id = useId();
13
13
  const { options } = draft;
14
+ const patch = (change: Parameters<typeof patchOptions>[1]): void => onChange(patchOptions(draft, change));
14
15
  if (dialect.engine === 'sqlite') {
15
16
  return (
16
17
  <div ref={ref} className={clsx('flex flex-col gap-4 p-3', className)}>
17
- <label className="flex items-start gap-3">
18
- <Switch
19
- label={t('designer.options.withoutRowid')}
20
- checked={options.withoutRowid}
21
- disabled={disabled}
22
- onCheckedChange={(checked) => onChange(patchOptions(draft, { withoutRowid: checked }))}
23
- />
24
- <span className="flex flex-col">
25
- <span className="text-sm text-text">{t('designer.options.withoutRowid')}</span>
26
- <span className="text-xs text-text-muted">{t('designer.options.withoutRowidHint')}</span>
27
- </span>
28
- </label>
29
- <label className="flex items-start gap-3">
30
- <Switch
31
- label={t('designer.options.strict')}
32
- checked={options.strict}
33
- disabled={disabled}
34
- onCheckedChange={(checked) => onChange(patchOptions(draft, { strict: checked }))}
35
- />
36
- <span className="flex flex-col">
37
- <span className="text-sm text-text">{t('designer.options.strict')}</span>
38
- <span className="text-xs text-text-muted">{t('designer.options.strictHint')}</span>
39
- </span>
40
- </label>
18
+ <OptionSwitch
19
+ label={t('designer.options.withoutRowid')}
20
+ hint={t('designer.options.withoutRowidHint')}
21
+ checked={options.withoutRowid}
22
+ disabled={disabled}
23
+ onCheckedChange={(withoutRowid) => patch({ withoutRowid })}
24
+ />
25
+ <OptionSwitch
26
+ label={t('designer.options.strict')}
27
+ hint={t('designer.options.strictHint')}
28
+ checked={options.strict}
29
+ disabled={disabled}
30
+ onCheckedChange={(strict) => patch({ strict })}
31
+ />
41
32
  </div>
42
33
  );
43
34
  }
44
35
  return (
45
36
  <div ref={ref} className={clsx('flex max-w-xl flex-col gap-4 p-3', className)}>
46
- <datalist id={`${id}-engines`}>
47
- {MYSQL_ENGINES.map((engine) => (
48
- <option key={engine} value={engine} />
49
- ))}
50
- </datalist>
51
- <datalist id={`${id}-charsets`}>
52
- {MYSQL_CHARSETS.map((charset) => (
53
- <option key={charset} value={charset} />
54
- ))}
55
- </datalist>
56
- <datalist id={`${id}-collations`}>
57
- {collationSuggestionsOf(options.charset).map((collation) => (
58
- <option key={collation} value={collation} />
59
- ))}
60
- </datalist>
61
- <Field label={t('designer.options.engine')} hint={t('designer.options.serverDefault')}>
62
- <Input
63
- mono
64
- list={`${id}-engines`}
65
- spellCheck={false}
66
- value={options.engine}
67
- disabled={disabled}
68
- onChange={(event) => onChange(patchOptions(draft, { engine: event.target.value }))}
69
- />
70
- </Field>
71
- <Field label={t('designer.options.charset')} hint={t('designer.options.serverDefault')}>
72
- <Input
73
- mono
74
- list={`${id}-charsets`}
75
- spellCheck={false}
76
- value={options.charset}
77
- disabled={disabled}
78
- onChange={(event) => onChange(patchOptions(draft, { charset: event.target.value }))}
79
- />
80
- </Field>
81
- <Field label={t('designer.options.collation')} hint={t('designer.options.serverDefault')}>
82
- <Input
83
- mono
84
- list={`${id}-collations`}
85
- spellCheck={false}
86
- value={options.collation}
87
- disabled={disabled}
88
- onChange={(event) => onChange(patchOptions(draft, { collation: event.target.value }))}
89
- />
90
- </Field>
37
+ <SuggestedField
38
+ label={t('designer.options.engine')}
39
+ listId={`${id}-engines`}
40
+ suggestions={MYSQL_ENGINES}
41
+ value={options.engine}
42
+ disabled={disabled}
43
+ onValueChange={(engine) => patch({ engine })}
44
+ />
45
+ <SuggestedField
46
+ label={t('designer.options.charset')}
47
+ listId={`${id}-charsets`}
48
+ suggestions={MYSQL_CHARSETS}
49
+ value={options.charset}
50
+ disabled={disabled}
51
+ onValueChange={(charset) => patch({ charset })}
52
+ />
53
+ <SuggestedField
54
+ label={t('designer.options.collation')}
55
+ listId={`${id}-collations`}
56
+ suggestions={collationSuggestionsOf(options.charset)}
57
+ value={options.collation}
58
+ disabled={disabled}
59
+ onValueChange={(collation) => patch({ collation })}
60
+ />
91
61
  <Field label={t('designer.options.comment')}>
92
- <TextArea
93
- rows={3}
94
- value={options.comment}
95
- disabled={disabled}
96
- onChange={(event) => onChange(patchOptions(draft, { comment: event.target.value }))}
97
- />
62
+ <TextArea rows={3} value={options.comment} disabled={disabled} onChange={(event) => patch({ comment: event.target.value })} />
98
63
  </Field>
99
64
  </div>
100
65
  );
101
66
  }
67
+
68
+ function OptionSwitch({
69
+ label,
70
+ hint,
71
+ checked,
72
+ disabled,
73
+ onCheckedChange
74
+ }: {
75
+ label: string;
76
+ hint: string;
77
+ checked: boolean;
78
+ disabled: boolean;
79
+ onCheckedChange(checked: boolean): void;
80
+ }) {
81
+ return (
82
+ <label className="flex items-start gap-3">
83
+ <Switch label={label} checked={checked} disabled={disabled} onCheckedChange={onCheckedChange} />
84
+ <span className="flex flex-col">
85
+ <span className="text-sm text-text">{label}</span>
86
+ <span className="text-xs text-text-muted">{hint}</span>
87
+ </span>
88
+ </label>
89
+ );
90
+ }
91
+
92
+ /* A free text field with a list of common values to pick from; empty leaves it to the server. */
93
+ function SuggestedField({
94
+ label,
95
+ listId,
96
+ suggestions,
97
+ value,
98
+ disabled,
99
+ onValueChange
100
+ }: {
101
+ label: string;
102
+ listId: string;
103
+ suggestions: readonly string[];
104
+ value: string;
105
+ disabled: boolean;
106
+ onValueChange(value: string): void;
107
+ }) {
108
+ const { t } = useTranslation('database');
109
+ return (
110
+ <>
111
+ <datalist id={listId}>
112
+ {suggestions.map((suggestion) => (
113
+ <option key={suggestion} value={suggestion} />
114
+ ))}
115
+ </datalist>
116
+ <Field label={label} hint={t('designer.options.serverDefault')}>
117
+ <Input mono list={listId} spellCheck={false} value={value} disabled={disabled} onChange={(event) => onValueChange(event.target.value)} />
118
+ </Field>
119
+ </>
120
+ );
121
+ }
@@ -5,8 +5,7 @@ import { useTranslation } from 'react-i18next';
5
5
  import { Button, Icon, copyText } from '@adecore/ui';
6
6
  import { CODE_TEXT } from '../code-text.ts';
7
7
  import type { DraftProblem } from '../ddl/index.ts';
8
- import type { Plan } from './plan.ts';
9
- import { scriptOf } from './plan.ts';
8
+ import { scriptOf, type Plan } from './plan.ts';
10
9
 
11
10
  export interface SqlPreviewProps {
12
11
  plan: Plan;
@@ -15,7 +14,7 @@ export interface SqlPreviewProps {
15
14
  }
16
15
 
17
16
  /* The sentence a problem of the draft is read as. */
18
- export function ProblemText({ problem }: { problem: DraftProblem }) {
17
+ function ProblemText({ problem }: { problem: DraftProblem }) {
19
18
  const { t } = useTranslation('database');
20
19
  return <>{t(`ddl.problem.${problem.code}`, { subject: problem.subject })}</>;
21
20
  }
@@ -104,13 +104,16 @@ export function TableDesigner({ connection, schema, table, toolbarStart, classNa
104
104
  const session = client.session(connection);
105
105
  const statements = plan.statements;
106
106
  const wasNew = snapshot.structure === null;
107
+ const settle = (message: string | null): void => {
108
+ setFailure(message);
109
+ setConfirming(false);
110
+ };
107
111
  let outcome: ExecuteResult;
108
112
  applying.current = true;
109
113
  try {
110
114
  outcome = await session.execute(statements.join(';\n'), { schema });
111
115
  } catch (e) {
112
- setFailure(messageOf(e, t('designer.failed')));
113
- setConfirming(false);
116
+ settle(messageOf(e, t('designer.failed')));
114
117
  return;
115
118
  } finally {
116
119
  applying.current = false;
@@ -118,12 +121,10 @@ export function TableDesigner({ connection, schema, table, toolbarStart, classNa
118
121
  const failed = outcome.results.find((result) => result.kind === 'error');
119
122
  if (failed?.kind === 'error') {
120
123
  await recoverFrom(session, statements, failed.sql, outcome.inTransaction);
121
- setFailure(failed.error.message);
122
- setConfirming(false);
124
+ settle(failed.error.message);
123
125
  return;
124
126
  }
125
- setFailure(null);
126
- setConfirming(false);
127
+ settle(null);
127
128
  // The client has told its listeners already: `execute` does for a statement that creates, alters or drops.
128
129
  if (wasNew || draft.name !== draft.originalName) {
129
130
  setSubject({ requested: table, current: draft.name });
@@ -11,6 +11,7 @@ import type { TableKind } from '../protocol/index.ts';
11
11
  import { EngineIcon } from '../connections/EngineIcon.tsx';
12
12
  import { keyLabelOf, type ColumnKeys } from '../column-keys.ts';
13
13
  import { KeyIcon } from '../KeyIcon.tsx';
14
+ import { usePopupPress } from '../use-popup-press.ts';
14
15
  import type { ExplorerFolder } from './folders.ts';
15
16
  import { RowMenu } from './RowMenu.tsx';
16
17
  import { TableDialog, type TableRequest } from './TableDialog.tsx';
@@ -31,12 +32,10 @@ import {
31
32
  tableOf,
32
33
  type EntryRow,
33
34
  type ErrorRow,
34
- type LoadTarget,
35
35
  type ExpandableRow,
36
36
  type TreeRow
37
37
  } from './tree.ts';
38
38
  import { useExplorerLoads } from './use-explorer-loads.ts';
39
- import { usePopupPress } from '../use-popup-press.ts';
40
39
  import { useStoredExpansion } from './use-stored-expansion.ts';
41
40
 
42
41
  export interface DatabaseExplorerProps {
@@ -228,10 +227,6 @@ export function DatabaseExplorer({
228
227
  }
229
228
  };
230
229
 
231
- const refresh = (target: LoadTarget): void => {
232
- loads.reset(target);
233
- };
234
-
235
230
  const disconnect = (connection: Connection): void => {
236
231
  void client.disconnect(connection.id).catch(() => undefined);
237
232
  loads.reset({ connectionId: connection.id });
@@ -259,9 +254,10 @@ export function DatabaseExplorer({
259
254
  row.kind === 'appItem' ? (
260
255
  (row.item.menu ?? null)
261
256
  ) : connection !== undefined && hasMenu(row) ? (
262
- <RowMenu row={row} connection={connection} onRefresh={refresh} onDisconnect={disconnect} onRequest={setRequest} />
257
+ <RowMenu row={row} connection={connection} onRefresh={loads.reset} onDisconnect={disconnect} onRequest={setRequest} />
263
258
  ) : null;
264
259
  const selectedRow = isSelected(row);
260
+ const draggable = row.kind === 'table' && onTableDragStart !== undefined;
265
261
  const props = {
266
262
  level: row.level,
267
263
  selected: selectedRow,
@@ -281,12 +277,9 @@ export function DatabaseExplorer({
281
277
  rowElements.current.set(row.key, element);
282
278
  }
283
279
  },
284
- draggable: row.kind === 'table' && onTableDragStart !== undefined,
285
- onDragStart:
286
- row.kind === 'table' && onTableDragStart !== undefined
287
- ? (event: DragEvent<HTMLElement>) => onTableDragStart(row.ref, event, row.table.kind)
288
- : undefined,
289
- onDragEnd: row.kind === 'table' && onTableDragStart !== undefined ? onTableDragEnd : undefined,
280
+ draggable,
281
+ onDragStart: draggable ? (event: DragEvent<HTMLElement>) => onTableDragStart(row.ref, event, row.table.kind) : undefined,
282
+ onDragEnd: draggable ? onTableDragEnd : undefined,
290
283
  onClick: (event: MouseEvent) => click(row, event.detail),
291
284
  onDoubleClick: tableOf(row) === null && row.kind !== 'appItem' ? undefined : () => open(row),
292
285
  onFocus: () => {
@@ -374,12 +367,9 @@ function RowContent({ row, onToggle }: { row: TreeRow; onToggle?: () => void })
374
367
  );
375
368
  case 'folder':
376
369
  return (
377
- <>
378
- <Tree.Chevron expanded={row.expanded} onExpandedChange={onToggle} />
379
- <Icon icon={Folder} size={16} className="shrink-0 text-text-muted" />
380
- <Tree.Label>{t(row.group === 'view' ? 'explorer.views' : 'explorer.tables')}</Tree.Label>
381
- <span className="shrink-0 text-text-faint tabular-nums">{formatNumber(row.count)}</span>
382
- </>
370
+ <FolderContent expanded={row.expanded} count={row.count} onToggle={onToggle}>
371
+ {t(row.group === 'view' ? 'explorer.views' : 'explorer.tables')}
372
+ </FolderContent>
383
373
  );
384
374
  case 'table':
385
375
  return (
@@ -391,21 +381,15 @@ function RowContent({ row, onToggle }: { row: TreeRow; onToggle?: () => void })
391
381
  );
392
382
  case 'part':
393
383
  return (
394
- <>
395
- <Tree.Chevron expanded={row.expanded} onExpandedChange={onToggle} />
396
- <Icon icon={Folder} size={16} className="shrink-0 text-text-muted" />
397
- <Tree.Label>{t(`explorer.parts.${row.part}`)}</Tree.Label>
398
- <span className="shrink-0 text-text-faint tabular-nums">{formatNumber(row.count)}</span>
399
- </>
384
+ <FolderContent expanded={row.expanded} count={row.count} onToggle={onToggle}>
385
+ {t(`explorer.parts.${row.part}`)}
386
+ </FolderContent>
400
387
  );
401
388
  case 'appFolder':
402
389
  return (
403
- <>
404
- <Tree.Chevron expanded={row.expanded} onExpandedChange={onToggle} />
405
- <Icon icon={Folder} size={16} className="shrink-0 text-text-muted" />
406
- <Tree.Label>{row.folder.label}</Tree.Label>
407
- <span className="shrink-0 text-text-faint tabular-nums">{formatNumber(row.count)}</span>
408
- </>
390
+ <FolderContent expanded={row.expanded} count={row.count} onToggle={onToggle}>
391
+ {row.folder.label}
392
+ </FolderContent>
409
393
  );
410
394
  case 'appItem':
411
395
  return (
@@ -446,6 +430,17 @@ function RowContent({ row, onToggle }: { row: TreeRow; onToggle?: () => void })
446
430
  }
447
431
  }
448
432
 
433
+ function FolderContent({ expanded, count, onToggle, children }: { expanded: boolean; count: number; onToggle?: () => void; children: ReactNode }) {
434
+ return (
435
+ <>
436
+ <Tree.Chevron expanded={expanded} onExpandedChange={onToggle} />
437
+ <Icon icon={Folder} size={16} className="shrink-0 text-text-muted" />
438
+ <Tree.Label>{children}</Tree.Label>
439
+ <span className="shrink-0 text-text-faint tabular-nums">{formatNumber(count)}</span>
440
+ </>
441
+ );
442
+ }
443
+
449
444
  const EMPTY_WORDS = { schemas: 'explorer.noSchemas', tables: 'explorer.noTables', columns: 'explorer.noColumns' } as const;
450
445
 
451
446
  function SchemaCount({ shown, total }: { shown: number; total: number }) {
@@ -32,7 +32,7 @@ export function TableDialog({ request, onClose, onDone }: TableDialogProps) {
32
32
  const dialect = dialectOf(await session.server());
33
33
  const { results } = await session.execute(statementOf(dialect, change, ref, kind, to));
34
34
  const failed = results.find((result) => result.kind === 'error');
35
- if (failed !== undefined && failed.kind === 'error') {
35
+ if (failed?.kind === 'error') {
36
36
  throw new Error(failed.error.message);
37
37
  }
38
38
  // The client tells its listeners after a `RENAME`, a `DROP` and a `TRUNCATE`, but not after a SQLite emptying, which is a `DELETE FROM`.
@@ -230,14 +230,7 @@ export const selectionOf = (row: TreeRow): ExplorerSelection | null => {
230
230
  };
231
231
 
232
232
  /* The key of the row a selection points at: its table, else its schema, else its connection. */
233
- export const selectionKey = (selection: ExplorerSelection): string => {
234
- if (selection.schema === undefined) {
235
- return connectionKey(selection.connectionId);
236
- }
237
- return selection.table === undefined
238
- ? schemaKey(selection.connectionId, selection.schema)
239
- : tableKey({ connectionId: selection.connectionId, schema: selection.schema, table: selection.table });
240
- };
233
+ export const selectionKey = (selection: ExplorerSelection): string => loadKey(selection);
241
234
 
242
235
  /* Whether a row has a menu: the rows of a connection, a schema, a table or a column, an item the app gave one, and none of the rows that only report. */
243
236
  export const hasMenu = (row: TreeRow): boolean =>
@@ -297,6 +290,10 @@ const statusDraft = (row: StatusRow, level: number, parent: string, focusable: b
297
290
  below: []
298
291
  });
299
292
 
293
+ /* The row of a load that is not ready: its error, which can be retried and so takes the focus, or that it is loading. */
294
+ const pendingDraft = (load: Exclude<Load<unknown>, { status: 'ready' }>, retry: LoadTarget, level: number, parent: string): Draft =>
295
+ statusDraft(load.status === 'error' ? { kind: 'error', message: load.message, retry } : { kind: 'loading' }, level, parent, load.status === 'error');
296
+
300
297
  const matches = (name: string, query: string): boolean => name.toLowerCase().includes(query);
301
298
 
302
299
  /* What each folder of a table besides its columns holds: the primary key and the unique keys, the foreign keys, the other indexes, the checks and the triggers. */
@@ -348,11 +345,7 @@ export const flattenTree = (input: TreeInput): TreeRow[] => {
348
345
  const tableChildren = (ref: TableRef, kind: TableKind, level: number, parent: string): Draft[] => {
349
346
  const load = input.structures(ref.connectionId, ref.schema, ref.table);
350
347
  if (load.status !== 'ready') {
351
- const row: StatusRow =
352
- load.status === 'error'
353
- ? { kind: 'error', message: load.message, retry: { connectionId: ref.connectionId, schema: ref.schema, table: ref.table } }
354
- : { kind: 'loading' };
355
- return [statusDraft(row, level, parent, load.status === 'error')];
348
+ return [pendingDraft(load, { connectionId: ref.connectionId, schema: ref.schema, table: ref.table }, level, parent)];
356
349
  }
357
350
  const structure = load.value;
358
351
  if (structure.columns.length === 0) {
@@ -443,9 +436,7 @@ export const flattenTree = (input: TreeInput): TreeRow[] => {
443
436
  if (filtering) {
444
437
  return { drafts: [], tables: 0 };
445
438
  }
446
- const row: StatusRow =
447
- load.status === 'error' ? { kind: 'error', message: load.message, retry: { connectionId: connection.id, schema } } : { kind: 'loading' };
448
- return { drafts: [statusDraft(row, level, parent, load.status === 'error')], tables: 0 };
439
+ return { drafts: [pendingDraft(load, { connectionId: connection.id, schema }, level, parent)], tables: 0 };
449
440
  };
450
441
 
451
442
  const schemaDraft = (connection: Connection, schema: string, parent: string): Draft | null => {
@@ -523,9 +514,7 @@ export const flattenTree = (input: TreeInput): TreeRow[] => {
523
514
  children = visible.flatMap((entry) => schemaDraft(connection, entry.name, key) ?? []);
524
515
  }
525
516
  } else if (open && !filtering) {
526
- const row: StatusRow =
527
- load.status === 'error' ? { kind: 'error', message: load.message, retry: { connectionId: connection.id } } : { kind: 'loading' };
528
- children = [statusDraft(row, 2, key, load.status === 'error')];
517
+ children = [pendingDraft(load, { connectionId: connection.id }, 2, key)];
529
518
  }
530
519
  // Behind a status row too: what the app keeps of a connection does not wait on its server.
531
520
  children = [...children, ...(open ? appFolders : [])];
@@ -16,7 +16,11 @@ import { Ban, Copy, RotateCcw } from 'lucide-react';
16
16
  import { useTranslation } from 'react-i18next';
17
17
  import { ContextMenu, copyText, EDIT_SHORTCUTS, Icon, isApplePlatform, isModHeld, Kbd, useContentSize } from '@adecore/ui';
18
18
  import type { EditValue, Value } from '../protocol/index.ts';
19
+ import { assignRef } from '../assign-ref.ts';
19
20
  import type { SqlTarget } from '../sql.ts';
21
+ import { useNumberNotation } from '../client-context.ts';
22
+ import { usePopupPress } from '../use-popup-press.ts';
23
+ import { useStableCallback } from '../use-stable-callback.ts';
20
24
  import type { RangeBlock } from './aggregates.ts';
21
25
  import { fullRect, rectBetween, rectContains } from './cell-range.ts';
22
26
  import type { EditMove } from './CellEditor.tsx';
@@ -49,9 +53,6 @@ import { moveFocus, type CellPosition, type NavigationKey } from './navigation.t
49
53
  import { selectRow } from './row-selection.ts';
50
54
  import { sortOnly, sortStateOf, type GridSort } from './sort.ts';
51
55
  import type { ColumnRequest, FocusedCell, FocusRequest, GridColumn, GridMenuContext, GridRow } from './types.ts';
52
- import { useNumberNotation } from '../client-context.ts';
53
- import { usePopupPress } from '../use-popup-press.ts';
54
- import { useStableCallback } from '../use-stable-callback.ts';
55
56
 
56
57
  export interface DataGridProps {
57
58
  columns: readonly GridColumn[];
@@ -174,7 +175,7 @@ export function DataGrid({
174
175
  const anchor = useRef(-1);
175
176
  const pressedInPopup = usePopupPress();
176
177
  const notation = useNumberNotation();
177
- /* Set once an edit commits or cancels, so the blur that follows the input's removal does not commit it again. */
178
+ // Set once an edit commits or cancels, so the blur that follows the input's removal does not commit it again.
178
179
  const settled = useRef(true);
179
180
  const dragging = useRef(false);
180
181
  const dragOrigin = useRef<CellPosition | null>(null);
@@ -252,9 +253,6 @@ export function DataGrid({
252
253
 
253
254
  useEffect(() => {
254
255
  reportFocus.current = onFocusedCellChange;
255
- });
256
-
257
- useEffect(() => {
258
256
  reportRange.current = onRangeChange;
259
257
  reportLayout.current = onLayoutChange;
260
258
  });
@@ -281,8 +279,11 @@ export function DataGrid({
281
279
  }, [blockKey]);
282
280
 
283
281
  useEffect(() => {
284
- const index = focusRequest === null || focusRequest === undefined ? -1 : rows.findIndex((row) => row.key === focusRequest.rowKey);
285
- if (focusRequest !== null && focusRequest !== undefined && index >= 0) {
282
+ if (focusRequest === null || focusRequest === undefined) {
283
+ return;
284
+ }
285
+ const index = rows.findIndex((row) => row.key === focusRequest.rowKey);
286
+ if (index >= 0) {
286
287
  focusAt({ row: index, column: focusRequest.column });
287
288
  if (focusRequest.quiet !== true) {
288
289
  scroller.current?.focus();
@@ -318,11 +319,7 @@ export function DataGrid({
318
319
  (node: HTMLDivElement | null) => {
319
320
  scroller.current = node;
320
321
  measure(node);
321
- if (typeof ref === 'function') {
322
- ref(node);
323
- } else if (ref) {
324
- ref.current = node;
325
- }
322
+ assignRef(ref, node);
326
323
  },
327
324
  [measure, ref]
328
325
  );
@@ -37,9 +37,10 @@ const LOOK = {
37
37
  */
38
38
  export function EnumPicker({ type, value, nullable, label, autoOpen = false, disabled = false, onDone, look = 'cell', className, ref }: EnumPickerProps) {
39
39
  const { t } = useTranslation('database');
40
- const settled = useRef(false);
41
- const [members, setMembers] = useState<ReadonlySet<string>>(() => new Set(type.kind === 'set' && value !== null ? membersOf(value) : []));
42
40
  const set = type.kind === 'set';
41
+ const membersOfValue = (): ReadonlySet<string> => new Set(set && value !== null ? membersOf(value) : []);
42
+ const settled = useRef(false);
43
+ const [members, setMembers] = useState(membersOfValue);
43
44
 
44
45
  const settle = (result: { readonly value: string | null } | undefined): void => {
45
46
  if (!settled.current) {
@@ -51,7 +52,7 @@ export function EnumPicker({ type, value, nullable, label, autoOpen = false, dis
51
52
  const handleOpenChange = (open: boolean): void => {
52
53
  if (open) {
53
54
  settled.current = false;
54
- setMembers(new Set(set && value !== null ? membersOf(value) : []));
55
+ setMembers(membersOfValue());
55
56
  return;
56
57
  }
57
58
  const joined = joinMembers(type, members);
@@ -7,9 +7,8 @@ import { formatNumber } from '@adecore/ui/format';
7
7
  import { CODE_TEXT } from '../code-text.ts';
8
8
  import { keyLabelOf } from '../column-keys.ts';
9
9
  import { KeyIcon } from '../KeyIcon.tsx';
10
- import type { SortDirection } from '../sql.ts';
10
+ import { isNumericKind, type SortDirection } from '../sql.ts';
11
11
  import type { ColumnClick } from './column-selection.ts';
12
- import { isNumericKind } from './display.ts';
13
12
  import { isDoubleClick, MAX_RESIZED_WIDTH, MIN_COLUMN_WIDTH } from './layout.ts';
14
13
  import type { GridColumn } from './types.ts';
15
14
 
@@ -140,7 +139,7 @@ export function GridHeaderCell({
140
139
  const { t } = useTranslation('database');
141
140
  const cell = useRef<HTMLDivElement>(null);
142
141
  const lastPress = useRef<number | null>(null);
143
- /* Set by a press on the resize strip, since the click that ends the drag would otherwise pick the column. */
142
+ // Set by a press on the resize strip, since the click that ends the drag would otherwise pick the column.
144
143
  const resized = useRef(false);
145
144
  const { startResize } = useColumnResize(cell, {
146
145
  size: width,
@@ -8,7 +8,8 @@ import { useNumberNotation } from '../client-context.ts';
8
8
  import { CODE_TEXT } from '../code-text.ts';
9
9
  import type { EditValue, Value } from '../protocol/index.ts';
10
10
  import { ValueEditor } from '../value/ValueEditor.tsx';
11
- import { cellView, copyTextOf, isNumericKind, isPreview, numeralText, type NumberNotation } from './display.ts';
11
+ import { isNumericKind } from '../sql.ts';
12
+ import { cellView, copyTextOf, isPreview, numeralText, type NumberNotation } from './display.ts';
12
13
  import { draftOf, parseDraft } from './edit-value.ts';
13
14
  import { parseEnumType } from './enum-type.ts';
14
15
  import { EnumPicker } from './EnumPicker.tsx';
@@ -1,6 +1,7 @@
1
1
  import { useRef, useState, type ReactNode, type Ref } from 'react';
2
2
  import clsx from 'clsx';
3
3
  import { ColumnResizeHandle, useColumnResize } from '@adecore/ui';
4
+ import { assignRef } from '../assign-ref.ts';
4
5
 
5
6
  export const VALUE_PANEL_WIDTH = 320;
6
7
  export const VALUE_PANEL_MIN_WIDTH = 240;
@@ -41,11 +42,7 @@ export function ValueDock({ open, panel, children, width: given, onWidthChange,
41
42
  <div
42
43
  ref={(node) => {
43
44
  root.current = node;
44
- if (typeof ref === 'function') {
45
- ref(node);
46
- } else if (ref) {
47
- ref.current = node;
48
- }
45
+ assignRef(ref, node);
49
46
  }}
50
47
  className={clsx('flex min-h-0 min-w-0 flex-1', className)}
51
48
  >
@@ -1,9 +1,7 @@
1
1
  import type { ValueKind } from '../protocol/index.ts';
2
+ import { isNumericKind, NUMERIC_TEXT } from '../sql.ts';
2
3
  import type { Shown } from './display.ts';
3
4
 
4
- const NUMERIC_KINDS: ReadonlySet<ValueKind> = new Set(['integer', 'decimal', 'float']);
5
- const NUMERIC_TEXT = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/;
6
-
7
5
  /* A block of rows, and the columns (indexes into the columns of the grid) it spans. */
8
6
  export interface RangeBlock {
9
7
  readonly top: number;
@@ -62,7 +60,7 @@ export const aggregateBlock = (rows: readonly (readonly Shown[])[], kinds: reado
62
60
  for (const column of block.columns) {
63
61
  count++;
64
62
  const kind = kinds[column];
65
- const found = kind !== undefined && NUMERIC_KINDS.has(kind) ? numberOf(cells[column] ?? null) : null;
63
+ const found = kind !== undefined && isNumericKind(kind) ? numberOf(cells[column] ?? null) : null;
66
64
  if (found === null) {
67
65
  continue;
68
66
  }
@@ -51,10 +51,11 @@ export const captureLayout = (columns: readonly { readonly name: string }[], sta
51
51
  widths[column.name] = width;
52
52
  }
53
53
  }
54
+ const namesOf = (indexes: readonly number[]): string[] => indexes.flatMap((index) => columns[index]?.name ?? []);
54
55
  return {
55
56
  widths,
56
- hidden: [...state.view.hidden].sort((a, b) => a - b).flatMap((index) => (columns[index] === undefined ? [] : [columns[index]!.name])),
57
- pinned: state.view.pinned.flatMap((index) => (columns[index] === undefined ? [] : [columns[index]!.name]))
57
+ hidden: namesOf([...state.view.hidden].sort((a, b) => a - b)),
58
+ pinned: namesOf(state.view.pinned)
58
59
  };
59
60
  };
60
61
 
@@ -1,5 +1,5 @@
1
1
  import type { ValueKind } from '../protocol/index.ts';
2
- import { qualifiedName, quoteIdentifier, sqlLiteral, type SqlTarget } from '../sql.ts';
2
+ import { isNumericKind, qualifiedName, quoteIdentifier, sqlLiteral, type SqlTarget } from '../sql.ts';
3
3
  import { copyTextOf, type Shown } from './display.ts';
4
4
 
5
5
  export type CopyFormat = 'tsv' | 'csv' | 'json' | 'sql';
@@ -19,7 +19,6 @@ export interface CopyInput {
19
19
  readonly target?: SqlTarget;
20
20
  }
21
21
 
22
- const NUMERIC_KINDS: ReadonlySet<ValueKind> = new Set(['integer', 'decimal', 'float']);
23
22
  const JSON_NUMBER = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/;
24
23
 
25
24
  const quoteField = (text: string): string => `"${text.replaceAll('"', '""')}"`;
@@ -45,7 +44,7 @@ const jsonOf = (cell: Shown, kind: ValueKind): string => {
45
44
  return Number.isFinite(cell) ? String(cell) : 'null';
46
45
  }
47
46
  if (typeof cell === 'string') {
48
- if (NUMERIC_KINDS.has(kind) && JSON_NUMBER.test(cell)) {
47
+ if (isNumericKind(kind) && JSON_NUMBER.test(cell)) {
49
48
  return cell;
50
49
  }
51
50
  if (kind === 'json') {