@adecore/database 0.17.0 → 0.18.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 (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 +251 -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,30 +1,29 @@
1
- import { useEffect, useRef, type KeyboardEvent, type Ref } from 'react';
1
+ import { useEffect, useImperativeHandle, useRef, type KeyboardEvent } from 'react';
2
+ import clsx from 'clsx';
2
3
  import { isApplePlatform, matchesShortcut, TextArea } from '@adecore/ui';
4
+ import { CODE_TEXT } from '../code-text.ts';
3
5
  import { applyEdit, indentEdit, newlineEdit, outdentEdit, type TextEdit } from './editing.ts';
6
+ import type { QueryConsoleEditorProps } from './editor-slot.ts';
4
7
  import { RUN_ALL_SHORTCUT, RUN_SHORTCUT } from './shortcuts.ts';
5
8
 
6
- /* What the person asked for: the selection or the statement under the caret, or the whole script. */
7
- export type RunScope = 'selection-or-statement' | 'all';
8
-
9
- export interface SqlEditorProps {
10
- value: string;
11
- onValueChange(value: string): void;
12
- /* Mod+Enter and Mod+Shift+Enter. */
13
- onRun(scope: RunScope): void;
14
- label: string;
15
- placeholder?: string;
16
- className?: string;
17
- /* The text area, which holds the selection and the caret the console reads when it runs. */
18
- ref?: Ref<HTMLTextAreaElement>;
19
- }
20
-
21
9
  /*
22
- * Where the SQL is typed. A plain text area for now, kept to itself so an editor with highlighting
23
- * and completion can take its place without the console noticing: it needs only these props.
10
+ * Where the SQL is typed when the app brings no editor of its own: a plain text area that fills the
11
+ * height it gets, with the same props an editor of the app receives.
24
12
  */
25
- export function SqlEditor({ value, onValueChange, onRun, label, placeholder, className, ref }: SqlEditorProps) {
13
+ export function SqlEditor({ ref, value, onValueChange, run, label, placeholder, autoFocus }: QueryConsoleEditorProps) {
14
+ const area = useRef<HTMLTextAreaElement>(null);
26
15
  const pendingSelection = useRef<{ area: HTMLTextAreaElement; start: number; end: number } | null>(null);
27
16
 
17
+ useImperativeHandle(ref, () => ({ selection: () => ({ start: area.current?.selectionStart ?? 0, end: area.current?.selectionEnd ?? 0 }) }), []);
18
+
19
+ useEffect(() => {
20
+ if (autoFocus) {
21
+ area.current?.focus();
22
+ }
23
+ // Only on mount: a console that later becomes active keeps the focus where the person put it.
24
+ // eslint-disable-next-line react/exhaustive-deps
25
+ }, []);
26
+
28
27
  useEffect(() => {
29
28
  const pending = pendingSelection.current;
30
29
  if (pending !== null) {
@@ -34,15 +33,15 @@ export function SqlEditor({ value, onValueChange, onRun, label, placeholder, cla
34
33
  }, [value]);
35
34
 
36
35
  // Through the browser's own insert, so Cmd+Z undoes the edit; the fallback sets the value itself.
37
- const apply = (area: HTMLTextAreaElement, edit: TextEdit): void => {
38
- const before = area.value;
39
- area.setSelectionRange(edit.from, edit.to);
36
+ const apply = (target: HTMLTextAreaElement, edit: TextEdit): void => {
37
+ const before = target.value;
38
+ target.setSelectionRange(edit.from, edit.to);
40
39
  const inserted = document.execCommand('insertText', false, edit.insert);
41
- if (inserted && area.value === applyEdit(before, edit)) {
42
- area.setSelectionRange(edit.selectionStart, edit.selectionEnd);
40
+ if (inserted && target.value === applyEdit(before, edit)) {
41
+ target.setSelectionRange(edit.selectionStart, edit.selectionEnd);
43
42
  return;
44
43
  }
45
- pendingSelection.current = { area, start: edit.selectionStart, end: edit.selectionEnd };
44
+ pendingSelection.current = { area: target, start: edit.selectionStart, end: edit.selectionEnd };
46
45
  onValueChange(applyEdit(before, edit));
47
46
  };
48
47
 
@@ -50,44 +49,41 @@ export function SqlEditor({ value, onValueChange, onRun, label, placeholder, cla
50
49
  const apple = isApplePlatform();
51
50
  if (matchesShortcut(RUN_ALL_SHORTCUT, event, apple)) {
52
51
  event.preventDefault();
53
- onRun('all');
52
+ run('all');
54
53
  return;
55
54
  }
56
55
  if (matchesShortcut(RUN_SHORTCUT, event, apple)) {
57
56
  event.preventDefault();
58
- onRun('selection-or-statement');
57
+ run('selection-or-statement');
59
58
  return;
60
59
  }
61
60
  if (event.nativeEvent.isComposing || event.metaKey || event.ctrlKey || event.altKey) {
62
61
  return;
63
62
  }
64
- const area = event.currentTarget;
65
- const { selectionStart, selectionEnd } = area;
63
+ const target = event.currentTarget;
64
+ const { selectionStart, selectionEnd } = target;
66
65
  if (event.key === 'Tab') {
67
66
  event.preventDefault();
68
- const edit = event.shiftKey ? outdentEdit(area.value, selectionStart, selectionEnd) : indentEdit(area.value, selectionStart, selectionEnd);
67
+ const edit = event.shiftKey ? outdentEdit(target.value, selectionStart, selectionEnd) : indentEdit(target.value, selectionStart, selectionEnd);
69
68
  if (edit !== null) {
70
- apply(area, edit);
69
+ apply(target, edit);
71
70
  }
72
71
  } else if (event.key === 'Enter' && !event.shiftKey) {
73
72
  event.preventDefault();
74
- apply(area, newlineEdit(area.value, selectionStart, selectionEnd));
73
+ apply(target, newlineEdit(target.value, selectionStart, selectionEnd));
75
74
  }
76
75
  };
77
76
 
78
77
  return (
79
78
  <TextArea
80
- ref={ref}
81
- mono
82
- resize="vertical"
79
+ ref={area}
83
80
  aria-label={label}
84
81
  placeholder={placeholder}
85
82
  spellCheck={false}
86
83
  autoCapitalize="off"
87
84
  autoCorrect="off"
88
- rows={8}
89
85
  value={value}
90
- className={className}
86
+ className={clsx(CODE_TEXT, 'flex-1')}
91
87
  onChange={(event) => onValueChange(event.target.value)}
92
88
  onKeyDown={handleKeyDown}
93
89
  />
@@ -1,14 +1,16 @@
1
- import { useMemo, useState } from 'react';
2
- import { ChevronLeft, ChevronRight, CircleAlert, Download, PanelRight } from 'lucide-react';
1
+ import { useMemo, useState, type ReactNode } from 'react';
2
+ import { ArrowUpRight, ChevronLeft, ChevronRight, CircleAlert, Download, PanelRight } from 'lucide-react';
3
3
  import { useTranslation } from 'react-i18next';
4
- import { Banner, IconButton, Menu, Spinner } from '@adecore/ui';
4
+ import { Banner, ButtonGroup, ContextMenu, Icon, IconButton, Menu, Spinner } from '@adecore/ui';
5
5
  import { formatDecimal, formatNumber } from '@adecore/ui/format';
6
6
  import { DataGrid } from '../grid/DataGrid.tsx';
7
7
  import { previewValueOf } from '../grid/focused-value.ts';
8
- import type { FocusedCell, GridRow } from '../grid/types.ts';
8
+ import type { FocusedCell, GridMenuContext, GridRow } from '../grid/types.ts';
9
9
  import { ValueDock } from '../grid/ValueDock.tsx';
10
10
  import type { Engine, FileFormat, StatementResult } from '../protocol/index.ts';
11
11
  import type { SqlTarget } from '../sql.ts';
12
+ import type { Reference } from '../table/references.ts';
13
+ import type { ResultKeys } from './result-keys.ts';
12
14
  import { ValuePanel } from '../value/ValuePanel.tsx';
13
15
 
14
16
  /* What "Copy as SQL INSERT" names the table of a result that comes from no table. */
@@ -39,6 +41,10 @@ export interface StatementResultViewProps {
39
41
  valuePanelOpen?: boolean;
40
42
  /* Offers the toggle of the value panel in the footer of a result set. */
41
43
  onValuePanelOpenChange?(open: boolean): void;
44
+ /* What the tables of the columns say about them, which marks the keys in the headers. */
45
+ keys?: ResultKeys | null;
46
+ /* Offers Go to referenced row on a cell of a foreign key, as the table view does. */
47
+ onFollowReference?(reference: Reference): void;
42
48
  }
43
49
 
44
50
  /* One statement's outcome: a result set in a read-only grid, a count of affected rows, or the error the server gave. */
@@ -50,7 +56,9 @@ export function StatementResultView({
50
56
  exporting = false,
51
57
  engine,
52
58
  valuePanelOpen = false,
53
- onValuePanelOpenChange
59
+ onValuePanelOpenChange,
60
+ keys = null,
61
+ onFollowReference
54
62
  }: StatementResultViewProps) {
55
63
  const { t } = useTranslation('database');
56
64
  const [focus, setFocus] = useState<FocusedCell | null>(null);
@@ -92,6 +100,27 @@ export function StatementResultView({
92
100
  );
93
101
  }
94
102
 
103
+ const referenceAt = (cell: FocusedCell): Reference | null => {
104
+ const row = rows.find((candidate) => candidate.key === cell.rowKey);
105
+ return keys === null || row === undefined ? null : keys.referenceAt(cell.column, row.cells);
106
+ };
107
+ const follow =
108
+ keys === null || onFollowReference === undefined
109
+ ? undefined
110
+ : (cell: FocusedCell): void => {
111
+ const reference = referenceAt(cell);
112
+ if (reference !== null) {
113
+ onFollowReference(reference);
114
+ }
115
+ };
116
+ const referenceMenu = (context: GridMenuContext): ReactNode =>
117
+ follow === undefined || context.cell === null || keys?.isReference(context.cell.column) !== true ? null : (
118
+ <ContextMenu.Item disabled={referenceAt(context.cell) === null} onClick={() => context.cell !== null && follow(context.cell)}>
119
+ <Icon icon={ArrowUpRight} size={14} />
120
+ {t('table.goToReferenced')}
121
+ </ContextMenu.Item>
122
+ );
123
+
95
124
  const focusedRow = valuePanelOpen && focus !== null ? rows.find((row) => row.key === focus.rowKey) : undefined;
96
125
  const focusedCell = focus === null ? undefined : focusedRow?.cells[focus.column];
97
126
 
@@ -112,11 +141,14 @@ export function StatementResultView({
112
141
  >
113
142
  <DataGrid
114
143
  label={t('console.result')}
115
- columns={result.columns}
144
+ columns={keys?.columns ?? result.columns}
116
145
  rows={rows}
117
146
  empty={t('console.noRows')}
118
147
  sqlTarget={sqlTarget}
119
148
  onFocusedCellChange={setFocus}
149
+ onFollow={follow}
150
+ canFollow={(cell) => referenceAt(cell) !== null}
151
+ menu={referenceMenu}
120
152
  />
121
153
  </ValueDock>
122
154
  <footer className="flex h-10 shrink-0 items-center gap-4 border-t border-border px-3 text-xs text-text-muted">
@@ -148,27 +180,29 @@ export function StatementResultView({
148
180
  />
149
181
  </>
150
182
  )}
151
- {onExport !== undefined && (
152
- <Menu.Root>
153
- <IconButton icon={Download} size="sm" label={t('console.export')} disabled={exporting} render={<Menu.Trigger />} />
154
- <Menu.Popup>
155
- {EXPORT_FORMATS.map((format) => (
156
- <Menu.Item key={format} onClick={() => onExport(format)}>
157
- {t('console.exportAs', { format: format.toUpperCase() })}
158
- </Menu.Item>
159
- ))}
160
- </Menu.Popup>
161
- </Menu.Root>
162
- )}
163
- {onValuePanelOpenChange !== undefined && (
164
- <IconButton
165
- icon={PanelRight}
166
- size="sm"
167
- label={t('console.valuePanel')}
168
- aria-pressed={valuePanelOpen}
169
- onClick={() => onValuePanelOpenChange(!valuePanelOpen)}
170
- />
171
- )}
183
+ <ButtonGroup>
184
+ {onExport !== undefined && (
185
+ <Menu.Root>
186
+ <IconButton icon={Download} size="sm" label={t('console.export')} disabled={exporting} render={<Menu.Trigger />} />
187
+ <Menu.Popup>
188
+ {EXPORT_FORMATS.map((format) => (
189
+ <Menu.Item key={format} onClick={() => onExport(format)}>
190
+ {t('console.exportAs', { format: format.toUpperCase() })}
191
+ </Menu.Item>
192
+ ))}
193
+ </Menu.Popup>
194
+ </Menu.Root>
195
+ )}
196
+ {onValuePanelOpenChange !== undefined && (
197
+ <IconButton
198
+ icon={PanelRight}
199
+ size="sm"
200
+ label={t('console.valuePanel')}
201
+ aria-pressed={valuePanelOpen}
202
+ onClick={() => onValuePanelOpenChange(!valuePanelOpen)}
203
+ />
204
+ )}
205
+ </ButtonGroup>
172
206
  </span>
173
207
  </footer>
174
208
  </>
@@ -1,7 +1,7 @@
1
1
  import type { Ref } from 'react';
2
2
  import clsx from 'clsx';
3
3
  import { useTranslation } from 'react-i18next';
4
- import { Button, Pill, Segmented, Tooltip } from '@adecore/ui';
4
+ import { Button, Pill, Switch, Tooltip } from '@adecore/ui';
5
5
 
6
6
  export type TransactionMode = 'auto' | 'manual';
7
7
 
@@ -18,23 +18,20 @@ export interface TransactionControlsProps {
18
18
  ref?: Ref<HTMLDivElement>;
19
19
  }
20
20
 
21
- /* Auto or manual commits, and while a transaction is open, the way to end it. */
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
  return (
25
25
  <div ref={ref} className={clsx('flex items-center gap-2', className)}>
26
26
  <Tooltip label={t('console.transaction.hint')}>
27
- <div className="flex">
28
- <Segmented
29
- value={mode}
30
- onValueChange={onModeChange}
27
+ <div className="flex items-center gap-2 text-xs text-text">
28
+ <Switch
29
+ checked={mode === 'auto'}
30
+ label={t('console.transaction.autoCommit')}
31
31
  disabled={busy}
32
- label={t('console.transaction.mode')}
33
- options={[
34
- { id: 'auto', label: t('console.transaction.auto') },
35
- { id: 'manual', label: t('console.transaction.manual') }
36
- ]}
32
+ onCheckedChange={(auto) => onModeChange(auto ? 'auto' : 'manual')}
37
33
  />
34
+ <span>{t('console.transaction.autoCommit')}</span>
38
35
  </div>
39
36
  </Tooltip>
40
37
  {open && (
@@ -0,0 +1,27 @@
1
+ import type { Ref } from 'react';
2
+
3
+ /* What a run takes: the selection, or the statement at the caret when nothing is selected; or the whole text. */
4
+ export type QueryConsoleRunScope = 'selection-or-statement' | 'all';
5
+
6
+ /* What the console asks the editor whenever it runs. */
7
+ export interface QueryConsoleEditorHandle {
8
+ /* Offsets into the text, `start` equal to `end` for a caret. */
9
+ selection(): { readonly start: number; readonly end: number };
10
+ }
11
+
12
+ /* What the console hands the editor it draws, its own or the app's. */
13
+ export interface QueryConsoleEditorProps {
14
+ /* Where the editor puts its handle. Without one, a run of the selection or the statement runs the whole text. */
15
+ ref: Ref<QueryConsoleEditorHandle>;
16
+ value: string;
17
+ onValueChange(sql: string): void;
18
+ /* What the editor's own keys call, as the toolbar does. It reads the selection through the handle, and does nothing while `busy` or when there is nothing to run. */
19
+ run(scope: QueryConsoleRunScope): void;
20
+ /* A run or the end of a transaction is under way. */
21
+ busy: boolean;
22
+ /* The accessible name of the editor and its placeholder, in the person's language. */
23
+ label: string;
24
+ placeholder: string;
25
+ /* The console was opened just now: the editor takes the caret once it is there. */
26
+ autoFocus: boolean;
27
+ }
@@ -0,0 +1,109 @@
1
+ import { useEffect, useMemo, useState } from 'react';
2
+ import type { DatabaseSession } from '../client/types.ts';
3
+ import type { Shown } from '../grid/display.ts';
4
+ import type { GridColumn } from '../grid/types.ts';
5
+ import type { ColumnSource, Engine, ResultColumn, StatementResult, TableStructure } from '../protocol/index.ts';
6
+ import { foreignKeyOf, referenceOf, type Reference } from '../table/references.ts';
7
+
8
+ /* What the tables a result reads from say about its columns: their keys, and where a foreign key points. */
9
+ export interface ResultKeys {
10
+ readonly columns: readonly GridColumn[];
11
+ /* Whether a column is part of a foreign key of the table it comes from. */
12
+ isReference(column: number): boolean;
13
+ /* The row the foreign key of a column points to, from the cells of a row of the result; `null` when it points nowhere. */
14
+ referenceAt(column: number, cells: readonly Shown[]): Reference | null;
15
+ }
16
+
17
+ type TableName = Pick<ColumnSource, 'schema' | 'table'>;
18
+
19
+ const tableKey = ({ schema, table }: TableName): string => `${schema}\u0000${table}`;
20
+
21
+ /* The tables the columns of a result come from, each once, in the order they first appear. */
22
+ export const sourceTablesOf = (columns: readonly ResultColumn[]): TableName[] => {
23
+ const tables = new Map<string, TableName>();
24
+ for (const { source } of columns) {
25
+ if (source !== undefined && !tables.has(tableKey(source))) {
26
+ tables.set(tableKey(source), { schema: source.schema, table: source.table });
27
+ }
28
+ }
29
+ return [...tables.values()];
30
+ };
31
+
32
+ /*
33
+ * The keys of a result's columns, from the structures of the tables they come from by key (`schema`, NUL,
34
+ * `table`). A column without a source, or of a table with no structure, is marked as nothing.
35
+ */
36
+ export const resultKeysOf = (engine: Engine, columns: readonly ResultColumn[], structures: ReadonlyMap<string, TableStructure>): ResultKeys => {
37
+ const structureOf = (column: ResultColumn | undefined): TableStructure | undefined =>
38
+ column?.source === undefined ? undefined : structures.get(tableKey(column.source));
39
+ const foreignKeyAt = (column: number) => {
40
+ const source = columns[column]?.source;
41
+ const structure = structureOf(columns[column]);
42
+ return source === undefined || structure === undefined ? undefined : foreignKeyOf(structure.foreignKeys, source.column);
43
+ };
44
+ return {
45
+ columns: columns.map((column): GridColumn => {
46
+ const structure = structureOf(column);
47
+ const name = column.source?.column;
48
+ if (structure === undefined || name === undefined) {
49
+ return column;
50
+ }
51
+ return {
52
+ ...column,
53
+ primaryKey: structure.primaryKey.includes(name),
54
+ foreignKey: structure.foreignKeys.some((key) => key.columns.includes(name))
55
+ };
56
+ }),
57
+ isReference: (column) => foreignKeyAt(column) !== undefined,
58
+ referenceAt: (column, cells) => {
59
+ const key = foreignKeyAt(column);
60
+ const source = columns[column]?.source;
61
+ if (key === undefined || source === undefined) {
62
+ return null;
63
+ }
64
+ // The columns of the result as the columns of that table, so a join names each key column of its own table only.
65
+ const asTable = columns.map((entry) => ({
66
+ name: entry.source !== undefined && tableKey(entry.source) === tableKey(source) ? entry.source.column : '',
67
+ kind: entry.kind
68
+ }));
69
+ return referenceOf(engine, key, asTable, cells);
70
+ }
71
+ };
72
+ };
73
+
74
+ /*
75
+ * The keys of a result set, once the structures of the tables its columns come from have loaded: each table
76
+ * once per result, through the console's session. `null` while they load and for what is no result set.
77
+ */
78
+ export function useResultKeys(session: DatabaseSession | null, engine: Engine | undefined, result: StatementResult | undefined): ResultKeys | null {
79
+ const [loaded, setLoaded] = useState<{ readonly result: StatementResult; readonly structures: ReadonlyMap<string, TableStructure> } | null>(null);
80
+
81
+ useEffect(() => {
82
+ if (session === null || result?.kind !== 'rows') {
83
+ return undefined;
84
+ }
85
+ const tables = sourceTablesOf(result.columns);
86
+ if (tables.length === 0) {
87
+ return undefined;
88
+ }
89
+ const controller = new AbortController();
90
+ // A table the server named but cannot describe, such as a derived one, marks no column.
91
+ const loads = tables.map((table) =>
92
+ session.structure(table.schema, table.table, { signal: controller.signal }).then(
93
+ (structure): [string, TableStructure] => [tableKey(table), structure],
94
+ () => null
95
+ )
96
+ );
97
+ void Promise.all(loads).then((entries) => {
98
+ if (!controller.signal.aborted) {
99
+ setLoaded({ result, structures: new Map(entries.filter((entry) => entry !== null)) });
100
+ }
101
+ });
102
+ return () => controller.abort();
103
+ }, [session, result]);
104
+
105
+ return useMemo(
106
+ () => (engine === undefined || result?.kind !== 'rows' || loaded?.result !== result ? null : resultKeysOf(engine, result.columns, loaded.structures)),
107
+ [engine, result, loaded]
108
+ );
109
+ }
@@ -2,7 +2,7 @@ import { useId } from 'react';
2
2
  import clsx from 'clsx';
3
3
  import { ArrowDown, ArrowUp, KeyRound, Plus, Trash2 } from 'lucide-react';
4
4
  import { useTranslation } from 'react-i18next';
5
- import { Button, Checkbox, EmptyState, Icon, IconButton, Input, Pill } from '@adecore/ui';
5
+ import { Button, ButtonGroup, Checkbox, EmptyState, Icon, IconButton, Input, Pill } from '@adecore/ui';
6
6
  import { formatNumber } from '@adecore/ui/format';
7
7
  import { typeSuggestionsOf } from '../ddl/index.ts';
8
8
  import type { EditorProps } from './editor-props.ts';
@@ -72,7 +72,7 @@ export function ColumnsEditor({ draft, dialect, disabled, onChange, className, r
72
72
  return (
73
73
  <tr key={column.key}>
74
74
  <td className={clsx(TD, 'whitespace-nowrap')}>
75
- <span className="flex">
75
+ <ButtonGroup>
76
76
  <IconButton
77
77
  icon={ArrowUp}
78
78
  size="xs"
@@ -87,7 +87,7 @@ export function ColumnsEditor({ draft, dialect, disabled, onChange, className, r
87
87
  disabled={disabled || at === draft.columns.length - 1}
88
88
  onClick={() => onChange(moveColumn(draft, column.key, 1))}
89
89
  />
90
- </span>
90
+ </ButtonGroup>
91
91
  </td>
92
92
  <td className={clsx(TD, 'min-w-40')}>
93
93
  <Input
@@ -1,9 +1,10 @@
1
- import type { KeyboardEvent, Ref } from 'react';
1
+ import type { KeyboardEvent, ReactNode, Ref } from 'react';
2
2
  import clsx from 'clsx';
3
3
  import { CircleAlert, Lock, Undo2 } from 'lucide-react';
4
4
  import { useTranslation } from 'react-i18next';
5
- import { Banner, Button, DisabledReason, Field, Icon, Input, PromptDialog, Tabs, isApplePlatform, matchesShortcut, shortcut } from '@adecore/ui';
5
+ import { Banner, Button, DisabledReason, Field, Icon, Input, PromptDialog, Separator, Tabs, isApplePlatform, matchesShortcut, shortcut } from '@adecore/ui';
6
6
  import { formatNumber } from '@adecore/ui/format';
7
+ import { CODE_TEXT } from '../code-text.ts';
7
8
  import type { Dialect, TableDraft } from '../ddl/index.ts';
8
9
  import { ColumnsEditor } from './ColumnsEditor.tsx';
9
10
  import { ForeignKeysEditor } from './ForeignKeysEditor.tsx';
@@ -39,6 +40,8 @@ export interface DesignerViewProps {
39
40
  onCancelConfirm(): void;
40
41
  onRevert(): void;
41
42
  onDismissFailure(): void;
43
+ /* The app's own content before the name of the table, such as where it is, with a separator after it. */
44
+ toolbarStart?: ReactNode;
42
45
  className?: string;
43
46
  ref?: Ref<HTMLDivElement>;
44
47
  }
@@ -64,6 +67,7 @@ export function DesignerView({
64
67
  onCancelConfirm,
65
68
  onRevert,
66
69
  onDismissFailure,
70
+ toolbarStart,
67
71
  className,
68
72
  ref
69
73
  }: DesignerViewProps) {
@@ -84,6 +88,13 @@ export function DesignerView({
84
88
  return (
85
89
  <div ref={ref} className={clsx('flex min-h-0 min-w-0 flex-col', className)} onKeyDown={handleKeyDown}>
86
90
  <div className="flex shrink-0 items-end gap-3 border-b border-border-soft px-3 py-2">
91
+ {toolbarStart !== undefined && toolbarStart !== null && toolbarStart !== false && (
92
+ // As tall as the field's input and level with it, since the field's label stands above the row.
93
+ <span className="flex h-8 shrink-0 items-center gap-3">
94
+ {toolbarStart}
95
+ <Separator />
96
+ </span>
97
+ )}
87
98
  <Field label={t('designer.tableName')} className="w-80">
88
99
  <Input
89
100
  mono
@@ -175,7 +186,7 @@ export function DesignerView({
175
186
  }}
176
187
  >
177
188
  {plan.destructive && <p className="mt-3 text-xs text-status-error">{t('designer.confirm.destructive')}</p>}
178
- <pre className="mt-3 max-h-64 overflow-auto rounded-md bg-surface-sunken p-2 font-mono text-code whitespace-pre text-text select-text">
189
+ <pre className={clsx(CODE_TEXT, 'mt-3 max-h-64 overflow-auto rounded-md bg-surface-sunken p-2 whitespace-pre text-text select-text')}>
179
190
  {scriptOf(plan.statements)}
180
191
  </pre>
181
192
  </PromptDialog>
@@ -3,6 +3,7 @@ import clsx from 'clsx';
3
3
  import { Copy } from 'lucide-react';
4
4
  import { useTranslation } from 'react-i18next';
5
5
  import { Button, Icon, copyText } from '@adecore/ui';
6
+ import { CODE_TEXT } from '../code-text.ts';
6
7
  import type { DraftProblem } from '../ddl/index.ts';
7
8
  import type { Plan } from './plan.ts';
8
9
  import { scriptOf } from './plan.ts';
@@ -48,7 +49,7 @@ export function SqlPreview({ plan, className, ref }: SqlPreviewProps) {
48
49
  ) : script === '' ? (
49
50
  <p className="text-xs text-text-faint">{t('designer.noChanges')}</p>
50
51
  ) : (
51
- <pre className="font-mono text-code whitespace-pre text-text">{script}</pre>
52
+ <pre className={clsx(CODE_TEXT, 'whitespace-pre text-text')}>{script}</pre>
52
53
  )}
53
54
  </div>
54
55
  </section>
@@ -1,4 +1,4 @@
1
- import { useMemo, useRef, useState, type Ref } from 'react';
1
+ import { useMemo, useRef, useState, type ReactNode, type Ref } from 'react';
2
2
  import clsx from 'clsx';
3
3
  import { CircleAlert, RefreshCw } from 'lucide-react';
4
4
  import { useTranslation } from 'react-i18next';
@@ -19,6 +19,8 @@ export interface TableDesignerProps {
19
19
  schema: string;
20
20
  /* The table to modify; without one the designer makes a new table. */
21
21
  table?: string;
22
+ /* The app's own content at the start of the toolbar, before the name of the table, such as where it is. */
23
+ toolbarStart?: ReactNode;
22
24
  className?: string;
23
25
  ref?: Ref<HTMLDivElement>;
24
26
  }
@@ -39,7 +41,7 @@ interface Editing {
39
41
  }
40
42
 
41
43
  /* Creates a table or modifies one: its columns, indexes, foreign keys and options, with the SQL it will run shown before it runs. */
42
- export function TableDesigner({ connection, schema, table, className, ref }: TableDesignerProps) {
44
+ export function TableDesigner({ connection, schema, table, toolbarStart, className, ref }: TableDesignerProps) {
43
45
  const { t } = useTranslation('database');
44
46
  const client = useDatabaseClient();
45
47
  const onAction = useDatabaseAction();
@@ -179,6 +181,7 @@ export function TableDesigner({ connection, schema, table, className, ref }: Tab
179
181
  setFailure(null);
180
182
  }}
181
183
  onDismissFailure={() => setFailure(null)}
184
+ toolbarStart={toolbarStart}
182
185
  />
183
186
  )}
184
187
  </div>