@adecore/database 0.17.0 → 0.18.1

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 (135) hide show
  1. package/README.md +1 -1
  2. package/dist/DatabaseProvider.d.ts +4 -3
  3. package/dist/DatabaseProvider.js +15 -3
  4. package/dist/KeyIcon.d.ts +4 -0
  5. package/dist/KeyIcon.js +14 -0
  6. package/dist/actions.d.ts +12 -0
  7. package/dist/client-context.d.ts +3 -1
  8. package/dist/client-context.js +2 -0
  9. package/dist/code-text.d.ts +1 -0
  10. package/dist/code-text.js +2 -0
  11. package/dist/column-keys.d.ts +5 -0
  12. package/dist/column-keys.js +2 -0
  13. package/dist/connections/ConnectionManager.d.ts +3 -2
  14. package/dist/connections/ConnectionManager.js +27 -16
  15. package/dist/connections/connection-config.js +2 -0
  16. package/dist/console/QueryConsole.d.ts +8 -3
  17. package/dist/console/QueryConsole.js +110 -50
  18. package/dist/console/SqlEditor.d.ts +2 -12
  19. package/dist/console/SqlEditor.js +29 -18
  20. package/dist/console/StatementResultView.d.ts +5 -1
  21. package/dist/console/StatementResultView.js +18 -5
  22. package/dist/console/TransactionControls.js +3 -6
  23. package/dist/console/editor-slot.d.ts +18 -0
  24. package/dist/console/editor-slot.js +1 -0
  25. package/dist/console/result-keys.d.ts +15 -0
  26. package/dist/console/result-keys.js +79 -0
  27. package/dist/designer/ColumnsEditor.js +2 -2
  28. package/dist/designer/DesignerView.d.ts +3 -2
  29. package/dist/designer/DesignerView.js +5 -4
  30. package/dist/designer/SqlPreview.js +2 -1
  31. package/dist/designer/TableDesigner.d.ts +3 -2
  32. package/dist/designer/TableDesigner.js +2 -2
  33. package/dist/explorer/DatabaseExplorer.d.ts +4 -1
  34. package/dist/explorer/DatabaseExplorer.js +79 -23
  35. package/dist/explorer/RowMenu.js +2 -2
  36. package/dist/explorer/expansion.js +2 -2
  37. package/dist/explorer/folders.d.ts +16 -0
  38. package/dist/explorer/folders.js +1 -0
  39. package/dist/explorer/tree.d.ts +55 -4
  40. package/dist/explorer/tree.js +137 -23
  41. package/dist/grid/CellEditor.js +3 -1
  42. package/dist/grid/DataGrid.d.ts +2 -2
  43. package/dist/grid/DataGrid.js +61 -59
  44. package/dist/grid/EnumPicker.js +2 -1
  45. package/dist/grid/GridHeaderCell.d.ts +1 -2
  46. package/dist/grid/GridHeaderCell.js +12 -5
  47. package/dist/grid/GridRowView.d.ts +49 -0
  48. package/dist/grid/GridRowView.js +51 -0
  49. package/dist/grid/RecordView.d.ts +4 -2
  50. package/dist/grid/RecordView.js +71 -22
  51. package/dist/grid/ValueDock.d.ts +3 -1
  52. package/dist/grid/ValueDock.js +9 -3
  53. package/dist/grid/cell-id.d.ts +1 -0
  54. package/dist/grid/cell-id.js +2 -0
  55. package/dist/grid/layout.d.ts +3 -2
  56. package/dist/grid/layout.js +15 -6
  57. package/dist/grid/types.d.ts +8 -1
  58. package/dist/grid/types.js +4 -3
  59. package/dist/index.d.ts +5 -1
  60. package/dist/index.js +2 -0
  61. package/dist/locales/en.json +32 -15
  62. package/dist/locales/nl.json +32 -15
  63. package/dist/protocol/index.d.ts +1 -1
  64. package/dist/protocol/messages.d.ts +1 -1
  65. package/dist/protocol/messages.js +1 -1
  66. package/dist/protocol/schema.d.ts +17 -0
  67. package/dist/structure/StructureView.d.ts +5 -3
  68. package/dist/structure/StructureView.js +8 -6
  69. package/dist/table/CommandField.js +2 -2
  70. package/dist/table/TableStatusBar.js +2 -2
  71. package/dist/table/TableToolbar.d.ts +3 -4
  72. package/dist/table/TableToolbar.js +4 -3
  73. package/dist/table/TableView.d.ts +4 -2
  74. package/dist/table/TableView.js +63 -91
  75. package/dist/table/grid-rows.js +4 -1
  76. package/dist/table/useTableTransfer.d.ts +3 -1
  77. package/dist/table/useTableTransfer.js +19 -5
  78. package/dist/testing/fake.d.ts +3 -1
  79. package/dist/testing/fake.js +7 -1
  80. package/dist/value/ValueEditor.d.ts +9 -0
  81. package/dist/value/ValueEditor.js +102 -0
  82. package/dist/value/ValuePanel.d.ts +1 -1
  83. package/dist/value/ValuePanel.js +3 -94
  84. package/dist/workbench/DatabaseWorkbench.js +4 -7
  85. package/package.json +6 -6
  86. package/src/DatabaseProvider.tsx +24 -5
  87. package/src/KeyIcon.tsx +15 -0
  88. package/src/actions.ts +28 -2
  89. package/src/client-context.ts +5 -1
  90. package/src/code-text.ts +2 -0
  91. package/src/column-keys.ts +8 -0
  92. package/src/connections/ConnectionManager.tsx +98 -68
  93. package/src/connections/connection-config.ts +2 -0
  94. package/src/console/QueryConsole.tsx +246 -150
  95. package/src/console/SqlEditor.tsx +33 -37
  96. package/src/console/StatementResultView.tsx +61 -27
  97. package/src/console/TransactionControls.tsx +8 -11
  98. package/src/console/editor-slot.ts +27 -0
  99. package/src/console/result-keys.ts +109 -0
  100. package/src/designer/ColumnsEditor.tsx +3 -3
  101. package/src/designer/DesignerView.tsx +14 -3
  102. package/src/designer/SqlPreview.tsx +2 -1
  103. package/src/designer/TableDesigner.tsx +5 -2
  104. package/src/explorer/DatabaseExplorer.tsx +170 -26
  105. package/src/explorer/RowMenu.tsx +2 -2
  106. package/src/explorer/expansion.ts +4 -4
  107. package/src/explorer/folders.ts +25 -0
  108. package/src/explorer/tree.ts +219 -28
  109. package/src/grid/CellEditor.tsx +3 -2
  110. package/src/grid/DataGrid.tsx +114 -176
  111. package/src/grid/EnumPicker.tsx +2 -1
  112. package/src/grid/GridHeaderCell.tsx +14 -9
  113. package/src/grid/GridRowView.tsx +184 -0
  114. package/src/grid/RecordView.tsx +188 -75
  115. package/src/grid/ValueDock.tsx +12 -3
  116. package/src/grid/cell-id.ts +2 -0
  117. package/src/grid/layout.ts +16 -6
  118. package/src/grid/types.ts +15 -3
  119. package/src/index.ts +5 -1
  120. package/src/locales/en.json +32 -15
  121. package/src/locales/nl.json +32 -15
  122. package/src/protocol/index.ts +13 -1
  123. package/src/protocol/messages.ts +1 -1
  124. package/src/protocol/schema.ts +28 -0
  125. package/src/structure/StructureView.tsx +16 -6
  126. package/src/table/CommandField.tsx +2 -2
  127. package/src/table/TableStatusBar.tsx +3 -3
  128. package/src/table/TableToolbar.tsx +107 -92
  129. package/src/table/TableView.tsx +95 -132
  130. package/src/table/grid-rows.ts +5 -3
  131. package/src/table/useTableTransfer.ts +21 -6
  132. package/src/testing/fake.ts +18 -1
  133. package/src/value/ValueEditor.tsx +213 -0
  134. package/src/value/ValuePanel.tsx +4 -206
  135. package/src/workbench/DatabaseWorkbench.tsx +9 -10
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode, type Ref } from 'react';
2
2
  import clsx from 'clsx';
3
3
  import { ArrowDownWideNarrow, ArrowUpRight, Check, CircleAlert, CopyPlus, Download, Filter, FilterX, RefreshCw, Trash2, Undo2 } from 'lucide-react';
4
+ import type { TFunction } from 'i18next';
4
5
  import { useTranslation } from 'react-i18next';
5
6
  import {
6
7
  Banner,
@@ -19,18 +20,17 @@ import {
19
20
  } from '@adecore/ui';
20
21
  import { formatNumber } from '@adecore/ui/format';
21
22
  import { DatabaseRequestError, type Connection } from '../client/types.ts';
22
- import { useDatabaseAction, useDatabaseClient, useDatabaseFiles, useDatabaseStorage } from '../client-context.ts';
23
+ import { useDatabaseAction, useDatabaseClient, useDatabaseFiles, useDatabaseNotice, useDatabaseStorage } from '../client-context.ts';
23
24
  import { aggregateBlock, blockSize, type RangeBlock } from '../grid/aggregates.ts';
24
25
  import { DataGrid } from '../grid/DataGrid.tsx';
25
- import { wholeValueOf } from '../grid/focused-value.ts';
26
26
  import type { GridSort } from '../grid/sort.ts';
27
- import type { ColumnRequest, FocusedCell, GridMenuContext } from '../grid/types.ts';
27
+ import type { ColumnRequest, FocusedCell, FocusRequest, GridMenuContext } from '../grid/types.ts';
28
28
  import { RecordView } from '../grid/RecordView.tsx';
29
29
  import { ValueDock } from '../grid/ValueDock.tsx';
30
30
  import { valueOfCell, type EditValue, type RowChange, type Value } from '../protocol/index.ts';
31
31
  import type { SqlTarget } from '../sql.ts';
32
32
  import { useSchemaChange } from '../use-schema-change.ts';
33
- import { ValuePanel } from '../value/ValuePanel.tsx';
33
+ import { useStableCallback } from '../use-stable-callback.ts';
34
34
  import {
35
35
  addChip,
36
36
  cellChip,
@@ -72,12 +72,27 @@ import { TableStatusBar } from './TableStatusBar.tsx';
72
72
  import { TableToolbar } from './TableToolbar.tsx';
73
73
  import { useLoaded } from './useLoaded.ts';
74
74
  import { useStoredLayout } from './useStoredLayout.ts';
75
- import { useTableTransfer } from './useTableTransfer.ts';
75
+ import { useTableTransfer, type TransferNotice } from './useTableTransfer.ts';
76
+
77
+ /* Where the width of the record view is kept, in pixels, for every table. */
78
+ export const RECORD_WIDTH_KEY = 'database:record-view';
79
+ const RECORD_WIDTH = 360;
80
+
81
+ const parseRecordWidth = (raw: string | null | undefined): number | null => {
82
+ try {
83
+ const value: unknown = JSON.parse(raw ?? 'null');
84
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.round(value) : null;
85
+ } catch {
86
+ return null;
87
+ }
88
+ };
76
89
 
77
90
  export interface TableViewProps {
78
91
  connection: Connection;
79
92
  schema: string;
80
93
  table: string;
94
+ /* The app's own content at the start of the toolbar, such as where the table is; the view draws the separators around the command field. */
95
+ toolbarStart?: ReactNode;
81
96
  /* The filters the view starts with, such as the condition of a jump along a foreign key. They win over the ones remembered. */
82
97
  defaultWhere?: string;
83
98
  defaultOrderBy?: string;
@@ -94,6 +109,20 @@ interface Filters {
94
109
 
95
110
  const NO_KEYS: ReadonlySet<string> = new Set();
96
111
 
112
+ /* What the strip over the table, or the app's notice, says of an export or an import. */
113
+ const transferText = (found: TransferNotice, t: TFunction<'database'>): string => {
114
+ switch (found.kind) {
115
+ case 'exporting':
116
+ return t('table.export.running', { format: found.format.toUpperCase() });
117
+ case 'exported':
118
+ return t('table.export.done', { count: found.rows, rows: formatNumber(found.rows) });
119
+ case 'imported':
120
+ return t('table.import.done', { count: found.rows, rows: formatNumber(found.rows) });
121
+ case 'failed':
122
+ return found.message;
123
+ }
124
+ };
125
+
97
126
  /* How much of a value a menu item spells out before it cuts it. */
98
127
  const MENU_VALUE_LENGTH = 40;
99
128
 
@@ -104,7 +133,7 @@ const DUPLICATE_ROWS = shortcut('Mod+D');
104
133
  * The rows of one table: read a page at a time, filtered and sorted with SQL a person types, and
105
134
  * editable in place. Changes pile up as pending and go to the server together on Submit.
106
135
  */
107
- export function TableView({ connection, schema, table, defaultWhere, defaultOrderBy, onDirtyChange, className, ref }: TableViewProps) {
136
+ export function TableView({ connection, schema, table, toolbarStart, defaultWhere, defaultOrderBy, onDirtyChange, className, ref }: TableViewProps) {
108
137
  // A new table, or the same one asked for with other filters, starts from nothing: no page, no pending change.
109
138
  return (
110
139
  <TableBody
@@ -112,6 +141,7 @@ export function TableView({ connection, schema, table, defaultWhere, defaultOrde
112
141
  connection={connection}
113
142
  schema={schema}
114
143
  table={table}
144
+ toolbarStart={toolbarStart}
115
145
  defaultWhere={defaultWhere}
116
146
  defaultOrderBy={defaultOrderBy}
117
147
  onDirtyChange={onDirtyChange}
@@ -121,12 +151,14 @@ export function TableView({ connection, schema, table, defaultWhere, defaultOrde
121
151
  );
122
152
  }
123
153
 
124
- function TableBody({ connection, schema, table, defaultWhere, defaultOrderBy, onDirtyChange, className, ref }: TableViewProps) {
154
+ function TableBody({ connection, schema, table, toolbarStart, defaultWhere, defaultOrderBy, onDirtyChange, className, ref }: TableViewProps) {
125
155
  const { t } = useTranslation('database');
126
156
  const client = useDatabaseClient();
127
157
  const onAction = useDatabaseAction();
158
+ const onNotice = useDatabaseNotice();
128
159
  const files = useDatabaseFiles();
129
- const { stored, remember } = useStoredLayout(useDatabaseStorage(), layoutStorageKey(connection.id, schema, table));
160
+ const storage = useDatabaseStorage();
161
+ const { stored, remember } = useStoredLayout(storage, layoutStorageKey(connection.id, schema, table));
130
162
  const session = useMemo(() => client.session(connection), [client, connection]);
131
163
  const grid = useRef<HTMLDivElement>(null);
132
164
  const command = useRef<HTMLInputElement>(null);
@@ -151,14 +183,12 @@ function TableBody({ connection, schema, table, defaultWhere, defaultOrderBy, on
151
183
  const [failure, setFailure] = useState<string | null>(null);
152
184
  const [shapeChanged, setShapeChanged] = useState(false);
153
185
  const [discard, setDiscard] = useState<{ run(): void } | null>(null);
154
- const [panelOpen, setPanelOpen] = useState(false);
155
186
  const [focus, setFocus] = useState<FocusedCell | null>(null);
156
187
  const [recordOpen, setRecordOpen] = useState(false);
157
- const [recordKey, setRecordKey] = useState<string | null>(null);
158
- const [focusRequest, setFocusRequest] = useState<FocusedCell | null>(null);
188
+ const [recordWidth, setRecordWidth] = useState(() => parseRecordWidth(storage?.get(RECORD_WIDTH_KEY)));
189
+ const [focusRequest, setFocusRequest] = useState<FocusRequest | null>(null);
159
190
  const [columnRequest, setColumnRequest] = useState<ColumnRequest | null>(null);
160
191
  const [range, setRange] = useState<RangeBlock | null>(null);
161
- const [fetched, setFetched] = useState<{ id: string; rows: unknown; value: Value | undefined } | null>(null);
162
192
 
163
193
  const applied = useMemo((): Filters => ({ where: chipsToWhere(chips), orderBy: chipsToOrderBy(engine, chips) }), [chips, engine]);
164
194
  const target = `${connection.id}|${schema}|${table}`;
@@ -209,7 +239,20 @@ function TableBody({ connection, schema, table, defaultWhere, defaultOrderBy, on
209
239
  onImported: () => {
210
240
  setCounted(null);
211
241
  rowsLoad.reload();
212
- }
242
+ },
243
+ report:
244
+ onNotice === undefined
245
+ ? undefined
246
+ : (found) =>
247
+ onNotice(
248
+ found.kind === 'failed'
249
+ ? {
250
+ tone: 'error',
251
+ title: t(found.during === 'export' ? 'table.export.failed' : 'table.import.readFailed'),
252
+ description: found.message
253
+ }
254
+ : { tone: 'success', title: transferText(found, t) }
255
+ )
213
256
  });
214
257
  const changeCount = pendingCount(pending);
215
258
  const dirty = changeCount > 0;
@@ -218,13 +261,12 @@ function TableBody({ connection, schema, table, defaultWhere, defaultOrderBy, on
218
261
  const sorts = useMemo(() => sortsOf(chips), [chips]);
219
262
  const commandColumns = useMemo((): CommandColumn[] => columns.map((column) => ({ name: column.name, type: column.type })), [columns]);
220
263
  const rowByKey = useMemo(() => new Map(rows.map((row) => [row.key, row])), [rows]);
221
- const recordIndex = Math.max(
222
- 0,
223
- rows.findIndex((row) => row.key === recordKey)
224
- );
264
+ // The record view shows the row of the focused cell, or the first row before a cell has the focus.
265
+ const focusedIndex = focus === null ? -1 : rows.findIndex((row) => row.key === focus.rowKey);
266
+ const recordIndex = focusedIndex >= 0 ? focusedIndex : rows.length > 0 ? 0 : -1;
225
267
  const selectionFigures = useMemo(
226
268
  () =>
227
- recordOpen || range === null || blockSize(range) < 2
269
+ range === null || blockSize(range) < 2
228
270
  ? null
229
271
  : {
230
272
  columns: range.columns.map((index) => columns[index]?.name ?? ''),
@@ -234,19 +276,9 @@ function TableBody({ connection, schema, table, defaultWhere, defaultOrderBy, on
234
276
  range
235
277
  )
236
278
  },
237
- [recordOpen, range, rows, columns]
279
+ [range, rows, columns]
238
280
  );
239
281
  const sqlTarget = useMemo((): SqlTarget => ({ engine, schema, table }), [engine, schema, table]);
240
- const focusedRow = panelOpen && focus !== null ? rows.find((row) => row.key === focus.rowKey) : undefined;
241
- const focusedCell = focus === null ? undefined : focusedRow?.cells[focus.column];
242
- const needsFetch =
243
- focusedCell !== undefined &&
244
- focus !== null &&
245
- wholeValueOf(focusedCell) === undefined &&
246
- !(focusedCell !== null && typeof focusedCell === 'object' && focusedCell.kind === 'default');
247
- const focusedRef = focus === null ? null : parseRowKey(focus.rowKey);
248
- const canFetch = focusedRef?.kind === 'loaded' && structure !== null && loaded !== null && rowKeyOf(structure, loaded, focusedRef.index) !== null;
249
- const fetchId = needsFetch && canFetch ? `${focus.rowKey}:${focus.column}` : null;
250
282
 
251
283
  useSchemaChange(connection.id, schema, () => {
252
284
  if (isPendingEmpty(pending)) {
@@ -286,31 +318,10 @@ function TableBody({ connection, schema, table, defaultWhere, defaultOrderBy, on
286
318
  }, [pending.inserts.length]);
287
319
 
288
320
  useEffect(() => {
289
- if (fetchId === null || focus === null || structure === null || loaded === null) {
290
- return;
291
- }
292
- const row = parseRowKey(focus.rowKey);
293
- const key = row.kind === 'loaded' ? rowKeyOf(structure, loaded, row.index) : null;
294
- const name = loaded.columns[focus.column]?.name;
295
- if (key === null || name === undefined) {
296
- return;
321
+ if (recordWidth !== null) {
322
+ storage?.set(RECORD_WIDTH_KEY, JSON.stringify(recordWidth));
297
323
  }
298
- const controller = new AbortController();
299
- session.cell(schema, table, key, name, { signal: controller.signal }).then(
300
- (value) => {
301
- if (!controller.signal.aborted) {
302
- setFetched({ id: fetchId, rows: loaded, value });
303
- }
304
- },
305
- (error: unknown) => {
306
- if (!controller.signal.aborted) {
307
- setFetched({ id: fetchId, rows: loaded, value: undefined });
308
- setFailure(messageOf(error));
309
- }
310
- }
311
- );
312
- return () => controller.abort();
313
- }, [fetchId, focus, structure, loaded, session, schema, table]);
324
+ }, [storage, recordWidth]);
314
325
 
315
326
  const discardPending = (): void => {
316
327
  setPending(emptyPending);
@@ -447,7 +458,7 @@ function TableBody({ connection, schema, table, defaultWhere, defaultOrderBy, on
447
458
  }
448
459
  };
449
460
 
450
- const loadValue = async (rowKey: string, column: number): Promise<Value | undefined> => {
461
+ const loadValue = useStableCallback(async (rowKey: string, column: number): Promise<Value | undefined> => {
451
462
  const row = parseRowKey(rowKey);
452
463
  const name = loaded?.columns[column]?.name;
453
464
  const key = row.kind === 'loaded' && structure !== null && loaded !== null ? rowKeyOf(structure, loaded, row.index) : null;
@@ -460,7 +471,7 @@ function TableBody({ connection, schema, table, defaultWhere, defaultOrderBy, on
460
471
  setFailure(messageOf(error));
461
472
  return undefined;
462
473
  }
463
- };
474
+ });
464
475
 
465
476
  const refsOf = (keys: Iterable<string>): RowSelectionRefs => {
466
477
  const loadedRows: number[] = [];
@@ -527,24 +538,19 @@ function TableBody({ connection, schema, table, defaultWhere, defaultOrderBy, on
527
538
  };
528
539
 
529
540
  const toggleRecordView = (): void => {
530
- if (recordOpen) {
531
- const row = rows[recordIndex];
532
- setRecordOpen(false);
533
- if (row !== undefined) {
534
- setFocusRequest({ rowKey: row.key, column: focus?.column ?? 0 });
535
- }
536
- return;
537
- }
538
- const focused = focus === null ? undefined : rows.find((row) => row.key === focus.rowKey);
539
- setRecordKey((focused ?? rows[0])?.key ?? null);
540
- setRecordOpen(true);
541
+ setRecordOpen((now) => !now);
542
+ };
543
+
544
+ /* The record view moves the grid's focus along with it, without taking the keyboard from its field. */
545
+ const followInGrid = (cell: FocusedCell): void => {
546
+ setFocus(cell);
547
+ setFocusRequest({ ...cell, quiet: true });
541
548
  };
542
549
 
543
550
  const changeRecordIndex = (index: number): void => {
544
551
  const row = rows[index];
545
552
  if (row !== undefined) {
546
- setRecordKey(row.key);
547
- setFocus((now) => (now === null ? now : { rowKey: row.key, column: now.column }));
553
+ followInGrid({ rowKey: row.key, column: focus?.column ?? 0 });
548
554
  }
549
555
  };
550
556
 
@@ -645,47 +651,16 @@ function TableBody({ connection, schema, table, defaultWhere, defaultOrderBy, on
645
651
  };
646
652
 
647
653
  const bounds = pageBounds(loadedOffset, loaded?.rows.length ?? 0, loaded?.hasMore ?? false, counted);
648
- const focusedColumn = focus === null ? null : (columns[focus.column] ?? null);
649
- const panelEditable =
650
- editable && focusedRow !== undefined && focusedRow.locked !== true && focusedRow.state !== 'deleted' && focusedColumn?.readOnly !== true;
651
- const fetchedNow = fetched !== null && fetched.id === fetchId && fetched.rows === loaded;
652
- const panelValue = ((): Value | undefined => {
653
- if (focusedCell === undefined || focusedRow === undefined || focus === null) {
654
- return undefined;
655
- }
656
- const whole = wholeValueOf(focusedCell);
657
- if (whole !== undefined) {
658
- return whole;
659
- }
660
- if (focusedCell !== null && typeof focusedCell === 'object' && focusedCell.kind === 'default') {
661
- // A pending DEFAULT holds no value of its own, so the panel keeps showing what the row had.
662
- const row = parseRowKey(focusedRow.key);
663
- return row.kind === 'loaded' && loaded !== null ? wholeValueOf(loaded.rows[row.index]?.[focus.column] ?? null) : null;
664
- }
665
- return fetchedNow ? fetched.value : undefined;
666
- })();
667
654
  const notice = failure ?? rowsLoad.error ?? structureLoad.error;
668
- const noticeText = (found: NonNullable<typeof transfer.notice>): string => {
669
- switch (found.kind) {
670
- case 'exporting':
671
- return t('table.export.running', { format: found.format.toUpperCase() });
672
- case 'exported':
673
- return t('table.export.done', { count: found.rows, rows: formatNumber(found.rows) });
674
- case 'imported':
675
- return t('table.import.done', { count: found.rows, rows: formatNumber(found.rows) });
676
- case 'failed':
677
- return found.message;
678
- }
679
- };
680
655
 
681
656
  return (
682
657
  <div ref={ref} className={clsx('flex min-h-0 flex-col bg-surface text-text', className)} onKeyDown={handleKeyDown}>
683
658
  <TableToolbar
659
+ start={toolbarStart}
684
660
  refreshing={rowsLoad.loading}
685
661
  readOnlyReason={readOnlyReason}
686
662
  hasSelection={selected.size > 0}
687
663
  canRevertSelection={selected.size > 0 && canRevert(selected)}
688
- valuePanelOpen={panelOpen}
689
664
  recordViewOpen={recordOpen}
690
665
  transfer={
691
666
  files === undefined
@@ -711,7 +686,6 @@ function TableBody({ connection, schema, table, defaultWhere, defaultOrderBy, on
711
686
  onDeleteRows={deleteSelected}
712
687
  onCloneRows={() => cloneRows([...selected])}
713
688
  onRevertRows={() => revertRowKeys(selected)}
714
- onToggleValuePanel={() => setPanelOpen((now) => !now)}
715
689
  onToggleRecordView={toggleRecordView}
716
690
  onPageSizeChange={changePageSize}
717
691
  onFirstPage={() => goToPage(0)}
@@ -743,7 +717,7 @@ function TableBody({ connection, schema, table, defaultWhere, defaultOrderBy, on
743
717
  <Banner
744
718
  icon={transfer.notice.kind === 'failed' ? CircleAlert : transfer.notice.kind === 'exporting' ? Download : Check}
745
719
  tone={transfer.notice.kind === 'failed' ? 'error' : 'neutral'}
746
- message={noticeText(transfer.notice)}
720
+ message={transferText(transfer.notice, t)}
747
721
  className="shrink-0 pt-2"
748
722
  >
749
723
  {transfer.notice.kind === 'exporting' ? (
@@ -765,26 +739,31 @@ function TableBody({ connection, schema, table, defaultWhere, defaultOrderBy, on
765
739
  ) : (
766
740
  <>
767
741
  <ValueDock
768
- open={panelOpen}
742
+ open={recordOpen}
743
+ width={recordWidth ?? RECORD_WIDTH}
744
+ onWidthChange={setRecordWidth}
769
745
  panel={
770
- <ValuePanel
771
- column={focusedColumn}
772
- value={panelValue}
773
- loading={fetchId !== null && !fetchedNow}
774
- editable={panelEditable}
775
- onCommit={(value) => focus !== null && commit(focus.rowKey, focus.column, value)}
776
- onClose={() => setPanelOpen(false)}
777
- className="min-w-0 flex-1"
746
+ <RecordView
747
+ label={t('table.recordFields', { table })}
748
+ columns={columns}
749
+ rows={rows}
750
+ index={recordIndex}
751
+ onIndexChange={changeRecordIndex}
752
+ focusedColumn={focus !== null && focusedIndex === recordIndex ? focus.column : null}
753
+ editable={editable}
754
+ onCommit={commit}
755
+ loadValue={loadValue}
756
+ onFocusedCellChange={followInGrid}
757
+ onFollow={onAction === undefined ? undefined : follow}
758
+ canFollow={(cell) => referenceAt(cell) !== null}
759
+ onClose={() => setRecordOpen(false)}
760
+ empty={t('table.empty')}
778
761
  />
779
762
  }
780
763
  >
781
764
  <div
782
765
  aria-busy={rowsLoad.loading}
783
- className={clsx(
784
- 'relative min-h-0 flex-1 flex-col',
785
- recordOpen ? 'hidden' : 'flex',
786
- rowsLoad.loading && 'pointer-events-none opacity-60'
787
- )}
766
+ className={clsx('relative flex min-h-0 flex-1 flex-col', rowsLoad.loading && 'pointer-events-none opacity-60')}
788
767
  >
789
768
  <DataGrid
790
769
  ref={grid}
@@ -814,22 +793,6 @@ function TableBody({ connection, schema, table, defaultWhere, defaultOrderBy, on
814
793
  columnRequest={columnRequest}
815
794
  />
816
795
  </div>
817
- {recordOpen && (
818
- <RecordView
819
- label={t('table.recordFields', { table })}
820
- columns={columns}
821
- rows={rows}
822
- index={recordIndex}
823
- onIndexChange={changeRecordIndex}
824
- editable={editable}
825
- onCommit={commit}
826
- loadValue={loadValue}
827
- onFocusedCellChange={setFocus}
828
- onFollow={onAction === undefined ? undefined : follow}
829
- canFollow={(cell) => referenceAt(cell) !== null}
830
- empty={t('table.empty')}
831
- />
832
- )}
833
796
  </ValueDock>
834
797
  <TableStatusBar
835
798
  elapsedMs={loaded.elapsedMs}
@@ -13,14 +13,16 @@ export const insertedRowKey = (id: number): string => `new:${id}`;
13
13
  export const parseRowKey = (key: string): RowRef =>
14
14
  key.startsWith('new:') ? { kind: 'inserted', id: Number(key.slice(4)) } : { kind: 'loaded', index: Number(key.slice(4)) };
15
15
 
16
- export const buildGridColumns = (loaded: Pick<RowsResult, 'columns'>, structure: TableStructure | null): GridColumn[] =>
17
- loaded.columns.map((column) =>
16
+ export const buildGridColumns = (loaded: Pick<RowsResult, 'columns'>, structure: TableStructure | null): GridColumn[] => {
17
+ const keys = { primaryKey: structure?.primaryKey ?? [], foreignKey: structure?.foreignKeys.flatMap((key) => key.columns) ?? [] };
18
+ return loaded.columns.map((column) =>
18
19
  gridColumnOf(
19
20
  column,
20
21
  structure?.columns.find((info) => info.name === column.name),
21
- structure?.primaryKey ?? []
22
+ keys
22
23
  )
23
24
  );
25
+ };
24
26
 
25
27
  export interface GridRowsInput {
26
28
  readonly structure: TableStructure | null;
@@ -11,7 +11,7 @@ export type TransferNotice =
11
11
  | { readonly kind: 'exporting'; readonly format: FileFormat }
12
12
  | { readonly kind: 'exported'; readonly rows: number }
13
13
  | { readonly kind: 'imported'; readonly rows: number }
14
- | { readonly kind: 'failed'; readonly message: string };
14
+ | { readonly kind: 'failed'; readonly during: 'export' | 'import'; readonly message: string };
15
15
 
16
16
  /* The import a person is setting up. */
17
17
  export interface ImportDraft {
@@ -38,6 +38,8 @@ export interface TableTransferInput {
38
38
  readonly orderBy: string;
39
39
  /* Rows were added; the table reloads. */
40
40
  readonly onImported: () => void;
41
+ /* Takes what finished or failed off the strip, for the app to show; the strip keeps only a running export. */
42
+ readonly report?: (notice: TransferNotice) => void;
41
43
  }
42
44
 
43
45
  export interface TableTransfer {
@@ -55,7 +57,7 @@ export interface TableTransfer {
55
57
  }
56
58
 
57
59
  /* Export of the table as filtered, and import of a file into it, with the state both need between the dialogs and the strip. */
58
- export function useTableTransfer({ client, session, files, schema, table, structure, where, orderBy, onImported }: TableTransferInput): TableTransfer {
60
+ export function useTableTransfer({ client, session, files, schema, table, structure, where, orderBy, onImported, report }: TableTransferInput): TableTransfer {
59
61
  const [notice, setNotice] = useState<TransferNotice | null>(null);
60
62
  const [importDraft, setImportDraft] = useState<ImportDraft | null>(null);
61
63
  const exporting = useRef<AbortController | null>(null);
@@ -72,6 +74,15 @@ export function useTableTransfer({ client, session, files, schema, table, struct
72
74
 
73
75
  useEffect(() => () => sampling.current?.abort(), []);
74
76
 
77
+ const show = (next: TransferNotice): void => {
78
+ if (report === undefined) {
79
+ setNotice(next);
80
+ } else {
81
+ setNotice(null);
82
+ report(next);
83
+ }
84
+ };
85
+
75
86
  const exportAs = (format: FileFormat): void => {
76
87
  if (files === undefined || exporting.current !== null) {
77
88
  return;
@@ -89,10 +100,14 @@ export function useTableTransfer({ client, session, files, schema, table, struct
89
100
  { source: { kind: 'table', schema, table, where: where || undefined, orderBy: orderBy || undefined }, format, path },
90
101
  { signal: controller.signal }
91
102
  );
92
- setNotice({ kind: 'exported', rows: result.rows });
103
+ show({ kind: 'exported', rows: result.rows });
93
104
  } catch (error) {
94
105
  const cancelled = controller.signal.aborted || (error instanceof DatabaseRequestError && error.code === 'cancelled');
95
- setNotice(cancelled ? null : { kind: 'failed', message: messageOf(error) });
106
+ if (cancelled) {
107
+ setNotice(null);
108
+ } else {
109
+ show({ kind: 'failed', during: 'export', message: messageOf(error) });
110
+ }
96
111
  } finally {
97
112
  exporting.current = null;
98
113
  }
@@ -120,7 +135,7 @@ export function useTableTransfer({ client, session, files, schema, table, struct
120
135
  const read = await sample(path, format, true);
121
136
  setImportDraft({ path, format, header: true, sample: read, mapping: matchColumns(read.columns, tableColumns, true), busy: false, error: null });
122
137
  } catch (error) {
123
- setNotice({ kind: 'failed', message: messageOf(error) });
138
+ show({ kind: 'failed', during: 'import', message: messageOf(error) });
124
139
  }
125
140
  })();
126
141
  };
@@ -159,7 +174,7 @@ export function useTableTransfer({ client, session, files, schema, table, struct
159
174
  try {
160
175
  const rows = await session.import(schema, table, { path, format, header, columns: mapping });
161
176
  setImportDraft(null);
162
- setNotice({ kind: 'imported', rows });
177
+ show({ kind: 'imported', rows });
163
178
  onImported();
164
179
  } catch (error) {
165
180
  setImportDraft((now) => (now === null ? now : { ...now, busy: false, error: messageOf(error) }));
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  Cell,
3
+ CheckInfo,
3
4
  ColumnInfo,
4
5
  DatabaseErrorCode,
5
6
  DatabaseMethod,
@@ -16,6 +17,7 @@ import type {
16
17
  ServerInfo,
17
18
  StatementResult,
18
19
  TableKind,
20
+ TriggerInfo,
19
21
  Value
20
22
  } from '../protocol/index.ts';
21
23
  import type { DatabaseTransport } from '../client/types.ts';
@@ -30,6 +32,9 @@ export interface FakeTable {
30
32
  readonly rows: readonly (readonly Value[])[];
31
33
  readonly indexes?: readonly IndexInfo[];
32
34
  readonly foreignKeys?: readonly ForeignKeyInfo[];
35
+ /* Left out, `structure` leaves them out too, as SQLite does for its checks. */
36
+ readonly checks?: readonly CheckInfo[];
37
+ readonly triggers?: readonly TriggerInfo[];
33
38
  readonly ddl?: string;
34
39
  }
35
40
 
@@ -50,12 +55,17 @@ export interface FakeDatabaseTransportOptions {
50
55
  }
51
56
 
52
57
  interface Table {
58
+ /* Where the table is, which the columns of a result read from it name as their source. */
59
+ schema: string;
60
+ name: string;
53
61
  kind: TableKind;
54
62
  columns: ColumnInfo[];
55
63
  primaryKey: string[];
56
64
  rows: Value[][];
57
65
  indexes: IndexInfo[];
58
66
  foreignKeys: ForeignKeyInfo[];
67
+ checks: CheckInfo[] | undefined;
68
+ triggers: TriggerInfo[] | undefined;
59
69
  ddl: string | null;
60
70
  }
61
71
 
@@ -92,12 +102,16 @@ const toTables = (database: FakeDatabase): Map<string, Map<string, Table>> =>
92
102
  Object.entries(tables).map(([name, table]) => [
93
103
  name,
94
104
  {
105
+ schema,
106
+ name,
95
107
  kind: table.kind ?? 'table',
96
108
  columns: structuredClone([...table.columns]),
97
109
  primaryKey: [...(table.primaryKey ?? [])],
98
110
  rows: structuredClone(table.rows.map((row) => [...row])),
99
111
  indexes: structuredClone([...(table.indexes ?? [])]),
100
112
  foreignKeys: structuredClone([...(table.foreignKeys ?? [])]),
113
+ checks: table.checks === undefined ? undefined : structuredClone([...table.checks]),
114
+ triggers: table.triggers === undefined ? undefined : structuredClone([...table.triggers]),
101
115
  ddl: table.ddl ?? null
102
116
  } satisfies Table
103
117
  ])
@@ -167,7 +181,8 @@ const toCell = (value: Value, cellLimit: number): Cell => {
167
181
  return value;
168
182
  };
169
183
 
170
- const toResultColumns = (table: Table): ResultColumn[] => table.columns.map(({ name, type, kind }) => ({ name, type, kind }));
184
+ const toResultColumns = (table: Table): ResultColumn[] =>
185
+ table.columns.map(({ name, type, kind }) => ({ name, type, kind, source: { schema: table.schema, table: table.name, column: name } }));
171
186
 
172
187
  const toRowsResult = (table: Table, rows: readonly (readonly Value[])[], offset: number, limit: number, cellLimit: number): RowsResult => ({
173
188
  columns: toResultColumns(table),
@@ -406,6 +421,8 @@ export const fakeDatabaseTransport = (options: FakeDatabaseTransportOptions): Da
406
421
  rowKey: rowKeyOf(table),
407
422
  indexes: table.indexes,
408
423
  foreignKeys: table.foreignKeys,
424
+ ...(table.checks === undefined ? {} : { checks: table.checks }),
425
+ ...(table.triggers === undefined ? {} : { triggers: table.triggers }),
409
426
  ddl: table.ddl
410
427
  };
411
428
  },