@adecore/database 0.17.0-beta.2 → 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
@@ -0,0 +1,184 @@
1
+ import { memo, type MouseEvent, type PointerEvent } from 'react';
2
+ import clsx from 'clsx';
3
+ import { Spinner } from '@adecore/ui';
4
+ import { formatNumber } from '@adecore/ui/format';
5
+ import { CODE_TEXT } from '../code-text.ts';
6
+ import { CellEditor, type EditMove } from './CellEditor.tsx';
7
+ import { cellIdOf } from './cell-id.ts';
8
+ import { cellView, type CellView, type NumberNotation } from './display.ts';
9
+ import type { EnumType } from './enum-type.ts';
10
+ import { EnumPicker } from './EnumPicker.tsx';
11
+ import { ROW_HEIGHT } from './layout.ts';
12
+ import type { CellPosition } from './navigation.ts';
13
+ import type { GridColumn, GridRow } from './types.ts';
14
+
15
+ /*
16
+ * The gutter and the pinned columns stick as one block, so the edge of one can never drift from the next, at any
17
+ * zoom. Each part paints its background under its own line as well: the line is an alpha, and what scrolls beneath
18
+ * the block would otherwise show through it.
19
+ */
20
+ export const PINNED_GROUP = 'sticky left-0 z-10 flex shrink-0';
21
+
22
+ const TONE: Record<CellView['tone'], string> = {
23
+ value: '',
24
+ null: 'text-text-faint',
25
+ default: 'text-text-faint italic'
26
+ };
27
+
28
+ const ROW_STATE = {
29
+ inserted: 'bg-positive/10',
30
+ deleted: 'bg-status-error/10 text-text-faint'
31
+ } as const;
32
+
33
+ export interface CellEdit extends CellPosition {
34
+ draft: string;
35
+ initial: string;
36
+ loading: boolean;
37
+ }
38
+
39
+ /* What every row draws the same way. It changes when the columns or their layout do, never on a scroll. */
40
+ export interface RowLook {
41
+ readonly gridId: string;
42
+ readonly columns: readonly GridColumn[];
43
+ /* Column indexes in the order they are drawn. */
44
+ readonly order: readonly number[];
45
+ /* By drawn position. */
46
+ readonly widths: readonly number[];
47
+ readonly pinnedShown: number;
48
+ readonly gutter: number;
49
+ readonly notation: NumberNotation;
50
+ readonly enumTypes: readonly (EnumType | null)[];
51
+ /* Column indexes picked whole. */
52
+ readonly picked: ReadonlySet<number>;
53
+ readonly loadingLabel: string;
54
+ }
55
+
56
+ /* What a row hands back to the grid; every function keeps its identity, so a row that did not change is not drawn again. */
57
+ export interface RowActions {
58
+ pressCell(event: PointerEvent, position: CellPosition, isEditing: boolean): void;
59
+ dragTo(position: CellPosition): void;
60
+ beginEdit(position: CellPosition): void;
61
+ openCellMenu(position: CellPosition): void;
62
+ pressRowNumber(index: number, event: MouseEvent): void;
63
+ openRowMenu(index: number): void;
64
+ finishPick(result: { readonly value: string | null } | undefined): void;
65
+ finishEdit(commit: boolean, move?: EditMove): void;
66
+ changeDraft(draft: string): void;
67
+ }
68
+
69
+ export interface GridRowViewProps {
70
+ row: GridRow;
71
+ index: number;
72
+ selected: boolean;
73
+ /* The column index of the focused cell when it is in this row, else -1. */
74
+ focusedColumn: number;
75
+ /* The drawn positions a cell range covers in this row, or -1 for both when it covers none. */
76
+ rangeStart: number;
77
+ rangeEnd: number;
78
+ /* The edit under way when it is in this row. */
79
+ editing: CellEdit | null;
80
+ look: RowLook;
81
+ actions: RowActions;
82
+ }
83
+
84
+ /* One row of the grid. Memoized on props that only change for this row, so a scroll draws the rows that came into view and none other. */
85
+ export const GridRowView = memo(function GridRowView({ row, index, selected, focusedColumn, rangeStart, rangeEnd, editing, look, actions }: GridRowViewProps) {
86
+ const { columns, order, widths, pinnedShown, gutter, notation, enumTypes, picked } = look;
87
+ const tint = row.state === undefined ? (selected ? 'bg-accent-soft/40' : '') : ROW_STATE[row.state];
88
+ const cells = order.map((columnIndex, position) => {
89
+ const column = columns[columnIndex]!;
90
+ const cell = row.cells[columnIndex] ?? null;
91
+ const view = cellView(cell, column.kind, notation);
92
+ const cellPosition = { row: index, column: columnIndex };
93
+ const isFocused = focusedColumn === columnIndex;
94
+ const ranged = picked.has(columnIndex) || (position >= rangeStart && position <= rangeEnd && rangeStart >= 0);
95
+ const edited = row.edited?.has(columnIndex) === true;
96
+ const isEditing = editing !== null && editing.column === columnIndex;
97
+ const pinned = position < pinnedShown;
98
+ const enumType = enumTypes[columnIndex] ?? null;
99
+ return (
100
+ <div
101
+ key={columnIndex}
102
+ id={cellIdOf(look.gridId, index, columnIndex)}
103
+ role="gridcell"
104
+ aria-colindex={position + 2}
105
+ aria-selected={ranged}
106
+ data-focused={isFocused ? '' : undefined}
107
+ data-ranged={ranged ? '' : undefined}
108
+ className={clsx(
109
+ CODE_TEXT,
110
+ 'flex h-full shrink-0 items-center overflow-hidden px-3 whitespace-nowrap outline-0 select-none data-[focused]:outline-1 data-[focused]:-outline-offset-1 data-[focused]:outline-border-strong group-focus-within/grid:data-[focused]:outline-accent',
111
+ position === order.length - 1 ? 'border-r-0' : 'border-r',
112
+ view.align === 'end' ? 'justify-end tabular-nums' : 'justify-start',
113
+ pinned
114
+ ? [
115
+ 'relative bg-clip-border',
116
+ position === pinnedShown - 1 ? 'border-border-strong' : 'border-border-soft',
117
+ ranged ? 'bg-accent-soft' : ['bg-surface', tint === '' && 'group-hover/row:bg-surface-hover']
118
+ ]
119
+ : ['relative border-border-soft', ranged ? 'bg-accent-soft' : edited && 'bg-accent/10']
120
+ )}
121
+ style={{ width: widths[position] }}
122
+ onPointerDown={(event) => actions.pressCell(event, cellPosition, isEditing)}
123
+ onPointerEnter={() => actions.dragTo(cellPosition)}
124
+ onDoubleClick={() => actions.beginEdit(cellPosition)}
125
+ onContextMenu={() => actions.openCellMenu(cellPosition)}
126
+ >
127
+ {pinned && !ranged && <span aria-hidden className={clsx('pointer-events-none absolute inset-0', tint, edited && 'bg-accent/10')} />}
128
+ {isEditing && editing.loading && <Spinner size={12} label={look.loadingLabel} />}
129
+ {isEditing && !editing.loading && enumType !== null && (
130
+ <EnumPicker
131
+ autoOpen
132
+ type={enumType}
133
+ value={typeof cell === 'string' ? cell : null}
134
+ nullable={column.nullable !== false}
135
+ label={column.name}
136
+ onDone={actions.finishPick}
137
+ />
138
+ )}
139
+ {isEditing && !editing.loading && enumType === null && (
140
+ <CellEditor
141
+ value={editing.draft}
142
+ label={column.name}
143
+ onValueChange={actions.changeDraft}
144
+ onCommit={(move) => actions.finishEdit(true, move)}
145
+ onCancel={() => actions.finishEdit(false)}
146
+ />
147
+ )}
148
+ {!isEditing && (
149
+ <>
150
+ <span className={clsx('min-w-0 truncate', TONE[view.tone], row.state === 'deleted' && 'line-through')}>{view.text}</span>
151
+ {view.suffix !== undefined && <span className="ml-2 shrink-0 text-text-faint">{view.suffix}</span>}
152
+ </>
153
+ )}
154
+ </div>
155
+ );
156
+ });
157
+ return (
158
+ <div
159
+ role="row"
160
+ aria-rowindex={index + 2}
161
+ aria-selected={selected}
162
+ className={clsx('group/row absolute inset-x-0 flex border-b border-border-soft', tint, tint === '' && 'hover:bg-surface-hover')}
163
+ style={{ top: index * ROW_HEIGHT, height: ROW_HEIGHT }}
164
+ >
165
+ <div className={PINNED_GROUP}>
166
+ <div
167
+ role="rowheader"
168
+ data-selected={selected ? '' : undefined}
169
+ className={clsx(
170
+ 'flex shrink-0 cursor-default items-center justify-end border-r border-border bg-surface bg-clip-border pr-2 pl-1 font-mono text-xs text-text-faint tabular-nums select-none data-[selected]:bg-accent-soft data-[selected]:text-text',
171
+ row.state === 'inserted' && 'text-positive'
172
+ )}
173
+ style={{ width: gutter }}
174
+ onClick={(event) => actions.pressRowNumber(index, event)}
175
+ onContextMenu={() => actions.openRowMenu(index)}
176
+ >
177
+ {row.number === null ? '+' : formatNumber(row.number)}
178
+ </div>
179
+ {cells.slice(0, pinnedShown)}
180
+ </div>
181
+ {cells.slice(pinnedShown)}
182
+ </div>
183
+ );
184
+ });
@@ -1,35 +1,41 @@
1
- import { useMemo, useRef, useState, type KeyboardEvent, type Ref } from 'react';
1
+ import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type Ref } from 'react';
2
2
  import clsx from 'clsx';
3
- import { ArrowUpRight, Ban, ChevronDown, ChevronUp, Copy, RotateCcw } from 'lucide-react';
3
+ import { ArrowUpRight, Ban, ChevronDown, ChevronUp, Copy, Maximize2, Minimize2, RotateCcw, X } from 'lucide-react';
4
4
  import { useTranslation } from 'react-i18next';
5
- import { ContextMenu, copyText, EDIT_SHORTCUTS, Icon, IconButton, Input, isApplePlatform, isModHeld, Kbd, shortcut, Spinner } from '@adecore/ui';
5
+ import { ButtonGroup, ContextMenu, copyText, EDIT_SHORTCUTS, Icon, IconButton, Input, isApplePlatform, isModHeld, Kbd, shortcut, Spinner } from '@adecore/ui';
6
6
  import { formatNumber } from '@adecore/ui/format';
7
7
  import { useNumberNotation } from '../client-context.ts';
8
+ import { CODE_TEXT } from '../code-text.ts';
8
9
  import type { EditValue, Value } from '../protocol/index.ts';
10
+ import { ValueEditor } from '../value/ValueEditor.tsx';
9
11
  import { cellView, copyTextOf, isNumericKind, isPreview, numeralText, type NumberNotation } from './display.ts';
10
12
  import { draftOf, parseDraft } from './edit-value.ts';
11
13
  import { parseEnumType } from './enum-type.ts';
12
14
  import { EnumPicker } from './EnumPicker.tsx';
15
+ import { wholeValueOf } from './focused-value.ts';
13
16
  import type { FocusedCell, GridColumn, GridRow } from './types.ts';
14
17
 
15
18
  export interface RecordViewProps {
16
19
  columns: readonly GridColumn[];
17
20
  rows: readonly GridRow[];
18
- /* The row shown, an index into `rows`. */
21
+ /* The row shown, an index into `rows`; -1 shows none. */
19
22
  index: number;
20
23
  onIndexChange(index: number): void;
24
+ /* The column of the grid's focused cell when it is in this row, which the view marks and keeps in sight. */
25
+ focusedColumn: number | null;
21
26
  /* Lets a field be edited; the rows that are `locked` stay as they are. */
22
27
  editable?: boolean;
23
28
  onCommit?(rowKey: string, column: number, value: EditValue): void;
24
29
  /* The whole value of a field that is only a preview. Resolves `undefined` when it cannot be had. */
25
30
  loadValue?(rowKey: string, column: number): Promise<Value | undefined>;
26
- /* The field that holds the focus changed, or lost it. */
27
- onFocusedCellChange?(cell: FocusedCell | null): void;
31
+ /* A field took the focus. */
32
+ onFocusedCellChange?(cell: FocusedCell): void;
28
33
  onFollow?(cell: FocusedCell): void;
29
34
  canFollow?(cell: FocusedCell): boolean;
35
+ onClose(): void;
30
36
  /* The accessible name of the list of fields. */
31
37
  label: string;
32
- /* Drawn while there are no rows. */
38
+ /* Drawn while there is no row. */
33
39
  empty?: string;
34
40
  className?: string;
35
41
  ref?: Ref<HTMLDivElement>;
@@ -59,22 +65,28 @@ const textOf = (cell: GridRow['cells'][number], kind: GridColumn['kind'], notati
59
65
  return draftOf(cell);
60
66
  };
61
67
 
68
+ /* A value that a field is too small for: a long text, JSON or binary, which opens up in place to the whole of it. */
69
+ const opensUp = (cell: GridRow['cells'][number], kind: GridColumn['kind']): boolean =>
70
+ kind === 'json' || kind === 'binary' || isPreview(cell) || (typeof cell === 'string' && cell.includes('\n'));
71
+
62
72
  /*
63
- * One row as a list of fields, the column's name and type beside an editor for its value. Edits go
64
- * to the same `onCommit` the grid uses, so they pile up as pending changes there. Up and Down move
65
- * between the fields; with Mod they move between the rows.
73
+ * The row of the grid's focused cell as a list of fields, beside the grid. It follows the grid's focus, and
74
+ * edits go to the same `onCommit` the grid uses, so they pile up as pending changes there. Up and Down move
75
+ * between the fields, Tab too; with Mod, Up and Down move between the rows.
66
76
  */
67
77
  export function RecordView({
68
78
  columns,
69
79
  rows,
70
80
  index,
71
81
  onIndexChange,
82
+ focusedColumn,
72
83
  editable = false,
73
84
  onCommit,
74
85
  loadValue,
75
86
  onFocusedCellChange,
76
87
  onFollow,
77
88
  canFollow,
89
+ onClose,
78
90
  label,
79
91
  empty,
80
92
  className,
@@ -83,11 +95,19 @@ export function RecordView({
83
95
  const { t } = useTranslation('database');
84
96
  const notation = useNumberNotation();
85
97
  const fields = useRef<(HTMLElement | null)[]>([]);
98
+ const blocks = useRef<(HTMLElement | null)[]>([]);
86
99
  const [editing, setEditing] = useState<Editing | null>(null);
87
- const row = rows[index];
100
+ const [opened, setOpened] = useState<ReadonlySet<number>>(() => new Set());
101
+ const row = index >= 0 ? rows[index] : undefined;
88
102
  const enumTypes = useMemo(() => columns.map((column) => parseEnumType(column.type)), [columns]);
89
103
  const apple = isApplePlatform();
90
104
 
105
+ useEffect(() => {
106
+ if (focusedColumn !== null) {
107
+ blocks.current[focusedColumn]?.scrollIntoView({ block: 'nearest' });
108
+ }
109
+ }, [focusedColumn, row?.key]);
110
+
91
111
  const canEdit = (column: number): boolean =>
92
112
  editable && row !== undefined && row.locked !== true && row.state !== 'deleted' && columns[column]?.readOnly !== true;
93
113
 
@@ -131,8 +151,19 @@ export function RecordView({
131
151
  );
132
152
  };
133
153
 
134
- const focusField = (column: number): void => {
135
- fields.current[column]?.focus();
154
+ const toggleOpened = (column: number): void => {
155
+ setOpened((now) => {
156
+ const next = new Set(now);
157
+ if (!next.delete(column)) {
158
+ next.add(column);
159
+ }
160
+ return next;
161
+ });
162
+ };
163
+
164
+ const moveRow = (to: number): void => {
165
+ commitEdit();
166
+ onIndexChange(Math.max(0, Math.min(to, rows.length - 1)));
136
167
  };
137
168
 
138
169
  const handleKeyDown = (event: KeyboardEvent<HTMLElement>, column: number): void => {
@@ -144,10 +175,9 @@ export function RecordView({
144
175
  event.preventDefault();
145
176
  const direction = event.key === 'ArrowUp' ? -1 : 1;
146
177
  if (mod) {
147
- commitEdit();
148
- onIndexChange(Math.max(0, Math.min(index + direction, rows.length - 1)));
178
+ moveRow(index + direction);
149
179
  } else {
150
- focusField(Math.max(0, Math.min(column + direction, columns.length - 1)));
180
+ fields.current[Math.max(0, Math.min(column + direction, columns.length - 1))]?.focus();
151
181
  }
152
182
  } else if (event.key === 'Enter') {
153
183
  event.preventDefault();
@@ -162,34 +192,37 @@ export function RecordView({
162
192
  const cellOf = (column: number): FocusedCell | null => (row === undefined ? null : { rowKey: row.key, column });
163
193
 
164
194
  return (
165
- <div ref={ref} className={clsx('flex min-h-0 flex-1 flex-col bg-surface', className)}>
166
- <div className="flex h-10 shrink-0 items-center gap-2 border-b border-border px-3">
167
- <IconButton
168
- icon={ChevronUp}
169
- size="sm"
170
- label={t('table.record.previous')}
171
- kbd={PREVIOUS_ROW}
172
- disabled={index <= 0}
173
- onClick={() => onIndexChange(index - 1)}
174
- />
175
- <IconButton
176
- icon={ChevronDown}
177
- size="sm"
178
- label={t('table.record.next')}
179
- kbd={NEXT_ROW}
180
- disabled={index >= rows.length - 1}
181
- onClick={() => onIndexChange(index + 1)}
182
- />
195
+ <div ref={ref} className={clsx('flex min-h-0 min-w-0 flex-1 flex-col bg-surface', className)}>
196
+ <div className="flex h-10 shrink-0 items-center gap-1 border-b border-border px-2">
197
+ <ButtonGroup>
198
+ <IconButton
199
+ icon={ChevronUp}
200
+ size="sm"
201
+ label={t('table.record.previous')}
202
+ kbd={PREVIOUS_ROW}
203
+ disabled={row === undefined || index <= 0}
204
+ onClick={() => moveRow(index - 1)}
205
+ />
206
+ <IconButton
207
+ icon={ChevronDown}
208
+ size="sm"
209
+ label={t('table.record.next')}
210
+ kbd={NEXT_ROW}
211
+ disabled={row === undefined || index >= rows.length - 1}
212
+ onClick={() => moveRow(index + 1)}
213
+ />
214
+ </ButtonGroup>
183
215
  {row !== undefined && (
184
- <>
185
- <span className="text-sm font-medium text-text tabular-nums">
216
+ <span className="flex min-w-0 items-baseline gap-2 px-1">
217
+ <span className="truncate text-sm font-medium text-text tabular-nums">
186
218
  {row.number === null ? t('table.record.newRow') : t('table.record.row', { number: formatNumber(row.number) })}
187
219
  </span>
188
- <span className="text-xs text-text-faint tabular-nums">
220
+ <span className="truncate text-xs text-text-faint tabular-nums">
189
221
  {t('table.record.position', { index: formatNumber(index + 1), count: formatNumber(rows.length) })}
190
222
  </span>
191
- </>
223
+ </span>
192
224
  )}
225
+ <IconButton icon={X} size="sm" label={t('table.record.close')} className="ml-auto" onClick={onClose} />
193
226
  </div>
194
227
  {row === undefined ? (
195
228
  <p className="px-4 py-6 text-xs text-text-muted">{empty}</p>
@@ -205,38 +238,75 @@ export function RecordView({
205
238
  const followable = cellKey !== null && onFollow !== undefined && (canFollow?.(cellKey) ?? true);
206
239
  const nullable = column.nullable !== false;
207
240
  const placeholder = cell === null ? 'NULL' : typeof cell === 'object' && cell.kind === 'default' ? 'DEFAULT' : undefined;
241
+ const expandable = enumType === null && opensUp(cell, column.kind);
242
+ const expanded = expandable && opened.has(position);
208
243
  return (
209
244
  <ContextMenu.Root key={position}>
210
245
  <ContextMenu.Trigger
211
- className={clsx('flex items-center gap-3 px-4 py-1', row.edited?.has(position) && 'bg-accent/10')}
212
- onFocusCapture={() => onFocusedCellChange?.(cellKey)}
246
+ ref={(node: HTMLElement | null) => {
247
+ blocks.current[position] = node;
248
+ }}
249
+ data-focused={focusedColumn === position ? '' : undefined}
250
+ className={clsx(
251
+ 'flex flex-col gap-1 border-l-2 border-transparent px-3 py-1.5 data-[focused]:border-accent data-[focused]:bg-accent-soft/40',
252
+ row.edited?.has(position) && 'bg-accent/10'
253
+ )}
254
+ onFocusCapture={() => cellKey !== null && onFocusedCellChange?.(cellKey)}
213
255
  >
214
- <span className="flex w-56 shrink-0 items-baseline gap-2 truncate">
215
- <span className="truncate font-mono text-code text-text">{column.name}</span>
216
- {column.type !== '' && <span className="truncate text-xs text-text-faint">{column.type}</span>}
256
+ <span className="flex min-w-0 items-center gap-2">
257
+ <span className={clsx(CODE_TEXT, 'min-w-0 truncate text-text')}>{column.name}</span>
258
+ {column.type !== '' && <span className="min-w-0 truncate text-xs text-text-faint">{column.type}</span>}
259
+ <ButtonGroup className="ml-auto shrink-0">
260
+ {followable && (
261
+ <IconButton
262
+ icon={ArrowUpRight}
263
+ size="xs"
264
+ label={t('grid.followReference')}
265
+ onClick={() => cellKey !== null && onFollow?.(cellKey)}
266
+ />
267
+ )}
268
+ {expandable && (
269
+ <IconButton
270
+ icon={expanded ? Minimize2 : Maximize2}
271
+ size="xs"
272
+ label={t(expanded ? 'table.record.collapse' : 'table.record.expand')}
273
+ aria-expanded={expanded}
274
+ onClick={() => toggleOpened(position)}
275
+ />
276
+ )}
277
+ </ButtonGroup>
217
278
  </span>
218
- <span className="relative flex min-w-0 flex-1 items-center gap-1">
219
- {enumType !== null ? (
220
- <EnumPicker
221
- look="field"
222
- type={enumType}
223
- value={typeof cell === 'string' ? cell : null}
224
- nullable={nullable}
225
- label={column.name}
226
- disabled={!editableField}
227
- ref={(node) => {
228
- fields.current[position] = node;
229
- }}
230
- onDone={(result) => {
231
- if (result !== undefined && result.value !== (typeof cell === 'string' ? cell : null)) {
232
- commitValue(position, result.value);
233
- }
234
- }}
235
- />
236
- ) : (
279
+ {expanded ? (
280
+ <WholeValue
281
+ key={`${row.key}:${position}`}
282
+ row={row}
283
+ column={position}
284
+ columns={columns}
285
+ editable={editableField}
286
+ loadValue={loadValue}
287
+ onCommit={(value) => commitValue(position, value)}
288
+ />
289
+ ) : enumType !== null ? (
290
+ <EnumPicker
291
+ look="field"
292
+ type={enumType}
293
+ value={typeof cell === 'string' ? cell : null}
294
+ nullable={nullable}
295
+ label={column.name}
296
+ disabled={!editableField}
297
+ ref={(node) => {
298
+ fields.current[position] = node;
299
+ }}
300
+ onDone={(result) => {
301
+ if (result !== undefined && result.value !== (typeof cell === 'string' ? cell : null)) {
302
+ commitValue(position, result.value);
303
+ }
304
+ }}
305
+ />
306
+ ) : (
307
+ <span className="relative flex min-w-0 items-center">
237
308
  <Input
238
309
  size="sm"
239
- mono
240
310
  spellCheck={false}
241
311
  aria-label={column.name}
242
312
  ref={(node) => {
@@ -245,23 +315,15 @@ export function RecordView({
245
315
  value={active ? editing.draft : textOf(cell, column.kind, notation, !editableField)}
246
316
  placeholder={placeholder}
247
317
  readOnly={!editableField || (preview && (!active || editing.loading))}
248
- className={clsx('min-w-0 flex-1', row.state === 'deleted' && 'line-through')}
318
+ className={clsx(CODE_TEXT, 'min-w-0 flex-1', row.state === 'deleted' && 'line-through')}
249
319
  onFocus={() => void beginEdit(position)}
250
320
  onBlur={commitEdit}
251
321
  onChange={(event) => changeDraft(position, event.target.value)}
252
322
  onKeyDown={(event) => handleKeyDown(event, position)}
253
323
  />
254
- )}
255
- {active && editing.loading && <Spinner size={12} label={t('grid.loadingValue')} className="absolute right-2" />}
256
- {followable && (
257
- <IconButton
258
- icon={ArrowUpRight}
259
- size="xs"
260
- label={t('grid.followReference')}
261
- onClick={() => cellKey !== null && onFollow?.(cellKey)}
262
- />
263
- )}
264
- </span>
324
+ {active && editing.loading && <Spinner size={12} label={t('grid.loadingValue')} className="absolute right-2" />}
325
+ </span>
326
+ )}
265
327
  </ContextMenu.Trigger>
266
328
  <ContextMenu.Popup>
267
329
  <ContextMenu.Item onClick={() => copyText(copyTextOf(cell))}>
@@ -291,3 +353,54 @@ export function RecordView({
291
353
  </div>
292
354
  );
293
355
  }
356
+
357
+ /* A field opened up to its whole value, fetched with `loadValue` when the row holds only a preview. */
358
+ function WholeValue({
359
+ row,
360
+ column,
361
+ columns,
362
+ editable,
363
+ loadValue,
364
+ onCommit
365
+ }: {
366
+ row: GridRow;
367
+ column: number;
368
+ columns: readonly GridColumn[];
369
+ editable: boolean;
370
+ loadValue?(rowKey: string, column: number): Promise<Value | undefined>;
371
+ onCommit(value: EditValue): void;
372
+ }) {
373
+ const { t } = useTranslation('database');
374
+ const cell = row.cells[column] ?? null;
375
+ const known = wholeValueOf(cell);
376
+ const [fetched, setFetched] = useState<{ cell: unknown; value: Value | undefined } | null>(null);
377
+ const value = known ?? (fetched !== null && fetched.cell === cell ? fetched.value : undefined);
378
+ const waiting = known === undefined && (fetched === null || fetched.cell !== cell);
379
+
380
+ useEffect(() => {
381
+ if (known !== undefined || loadValue === undefined) {
382
+ return;
383
+ }
384
+ let live = true;
385
+ void loadValue(row.key, column).then((whole) => {
386
+ if (live) {
387
+ setFetched({ cell, value: whole });
388
+ }
389
+ });
390
+ return () => {
391
+ live = false;
392
+ };
393
+ }, [known, loadValue, row.key, column, cell]);
394
+
395
+ return (
396
+ <div className="flex h-72 min-h-0 flex-col overflow-hidden rounded-md border border-border bg-surface">
397
+ {value !== undefined ? (
398
+ <ValueEditor column={columns[column]!} value={value} editable={editable} onCommit={onCommit} />
399
+ ) : (
400
+ <div className="grid min-h-0 grow place-items-center text-xs text-text-muted">
401
+ {waiting && loadValue !== undefined ? <Spinner size={14} label={t('value.loading')} /> : t('value.notLoaded')}
402
+ </div>
403
+ )}
404
+ </div>
405
+ );
406
+ }
@@ -11,21 +11,30 @@ export interface ValueDockProps {
11
11
  panel: ReactNode;
12
12
  /* The grid and whatever shares its column. */
13
13
  children: ReactNode;
14
+ /* The width of the panel, when the owner keeps it; without it the dock keeps it itself. */
15
+ width?: number;
16
+ onWidthChange?(width: number): void;
14
17
  className?: string;
15
18
  ref?: Ref<HTMLDivElement>;
16
19
  }
17
20
 
18
21
  /* A grid with a panel along its right edge that a person can widen by its left edge. */
19
- export function ValueDock({ open, panel, children, className, ref }: ValueDockProps) {
22
+ export function ValueDock({ open, panel, children, width: given, onWidthChange, className, ref }: ValueDockProps) {
20
23
  const root = useRef<HTMLDivElement | null>(null);
21
24
  const dock = useRef<HTMLDivElement>(null);
22
- const [width, setWidth] = useState(VALUE_PANEL_WIDTH);
25
+ const [own, setOwn] = useState(VALUE_PANEL_WIDTH);
26
+ const width = given ?? own;
23
27
  const { startResize } = useColumnResize(dock, {
24
28
  size: width,
25
29
  min: VALUE_PANEL_MIN_WIDTH,
26
30
  from: 'right',
27
31
  max: () => (root.current?.clientWidth ?? 960) - VALUE_PANEL_MIN_WIDTH,
28
- onSize: setWidth
32
+ onSize: (next) => {
33
+ if (given === undefined) {
34
+ setOwn(next);
35
+ }
36
+ onWidthChange?.(next);
37
+ }
29
38
  });
30
39
 
31
40
  return (
@@ -0,0 +1,2 @@
1
+ /* The id of a cell, which the grid's `aria-activedescendant` points at. */
2
+ export const cellIdOf = (gridId: string, row: number, column: number): string => `${gridId}-${row}-${column}`;
@@ -7,7 +7,8 @@ export const MAX_COLUMN_WIDTH = 320;
7
7
  export const MAX_RESIZED_WIDTH = 1200;
8
8
  /* How wide fitting a column to its content may make it. */
9
9
  export const MAX_FIT_WIDTH = 640;
10
- export const OVERSCAN_ROWS = 6;
10
+ /* The window of drawn rows moves in blocks of this many rows, so a scroll within a block draws nothing. */
11
+ export const ROW_BLOCK = 16;
11
12
  /* Two presses on a resize handle this close are a double click. */
12
13
  export const DOUBLE_CLICK_MS = 350;
13
14
  /* What a viewport is taken to be until it is measured, so a first paint already holds rows. */
@@ -29,14 +30,23 @@ export interface VisibleRange {
29
30
  readonly end: number;
30
31
  }
31
32
 
32
- /* The rows that touch a viewport of `height` pixels starting `top` pixels into the rows, plus `overscan` rows on both sides. */
33
- export const visibleRange = (top: number, height: number, rowHeight: number, count: number, overscan: number): VisibleRange => {
33
+ /* The block the top of the viewport is in, for a viewport `top` pixels into the rows. */
34
+ export const scrollBlock = (top: number): number => Math.floor(Math.max(0, top) / (ROW_BLOCK * ROW_HEIGHT));
35
+
36
+ /*
37
+ * The rows to draw while the top of a viewport `height` pixels tall is in `block`: those in view and a
38
+ * viewport more on each side, rounded out to whole blocks. A fast scroll finds its next rows drawn, and
39
+ * the window moves, and the grid draws, once per block instead of once per row.
40
+ */
41
+ export const drawnRange = (block: number, height: number, count: number): VisibleRange => {
34
42
  if (count <= 0 || height <= 0) {
35
43
  return { start: 0, end: 0 };
36
44
  }
37
- const first = Math.floor(Math.max(0, top) / rowHeight);
38
- const last = Math.ceil(Math.max(0, top + height) / rowHeight);
39
- return { start: Math.max(0, Math.min(first, count) - overscan), end: Math.min(count, last + overscan) };
45
+ const page = Math.ceil(height / ROW_HEIGHT);
46
+ const top = Math.max(0, block) * ROW_BLOCK;
47
+ const start = Math.max(0, Math.floor((top - page) / ROW_BLOCK) * ROW_BLOCK);
48
+ const end = Math.min(count, Math.ceil((top + ROW_BLOCK + 2 * page) / ROW_BLOCK) * ROW_BLOCK);
49
+ return { start: Math.min(start, end), end };
40
50
  };
41
51
 
42
52
  /*