@adecore/database 0.23.0 → 0.24.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 (58) hide show
  1. package/README.md +2 -2
  2. package/dist/client/client.js +31 -11
  3. package/dist/client/types.d.ts +3 -0
  4. package/dist/console/QueryConsole.js +8 -10
  5. package/dist/console/SqlEditor.js +5 -10
  6. package/dist/console/StatementResultView.d.ts +3 -1
  7. package/dist/console/StatementResultView.js +5 -2
  8. package/dist/console/shortcuts.d.ts +3 -0
  9. package/dist/console/shortcuts.js +14 -3
  10. package/dist/grid/DataGrid.d.ts +3 -1
  11. package/dist/grid/DataGrid.js +35 -22
  12. package/dist/grid/GridRowView.js +2 -2
  13. package/dist/grid/RecordView.js +2 -2
  14. package/dist/grid/display.d.ts +2 -0
  15. package/dist/grid/display.js +11 -0
  16. package/dist/grid/whole-cells.d.ts +9 -0
  17. package/dist/grid/whole-cells.js +49 -0
  18. package/dist/host/helper-path.d.ts +2 -1
  19. package/dist/host/helper-path.js +26 -5
  20. package/dist/host/host.js +5 -0
  21. package/dist/host/validate.js +8 -3
  22. package/dist/index.d.ts +1 -0
  23. package/dist/index.js +1 -0
  24. package/dist/locales/en.d.ts +601 -0
  25. package/dist/locales/en.js +600 -0
  26. package/dist/locales/nl.d.ts +601 -0
  27. package/dist/locales/nl.js +600 -0
  28. package/dist/locales.js +3 -2
  29. package/dist/protocol/messages.d.ts +4 -1
  30. package/dist/protocol/messages.js +1 -1
  31. package/dist/table/TableView.js +1 -1
  32. package/dist/testing/fake.js +24 -14
  33. package/package.json +8 -8
  34. package/src/client/client.ts +32 -11
  35. package/src/client/types.ts +10 -2
  36. package/src/console/QueryConsole.tsx +10 -9
  37. package/src/console/SqlEditor.tsx +5 -10
  38. package/src/console/StatementResultView.tsx +15 -1
  39. package/src/console/shortcuts.ts +16 -3
  40. package/src/grid/DataGrid.tsx +47 -24
  41. package/src/grid/GridRowView.tsx +2 -2
  42. package/src/grid/RecordView.tsx +2 -2
  43. package/src/grid/display.ts +13 -0
  44. package/src/grid/whole-cells.ts +72 -0
  45. package/src/host/helper-path.ts +42 -4
  46. package/src/host/host.ts +6 -0
  47. package/src/host/validate.ts +9 -3
  48. package/src/index.ts +1 -0
  49. package/src/locales/en.ts +600 -0
  50. package/src/locales/nl.ts +600 -0
  51. package/src/locales.ts +3 -2
  52. package/src/protocol/messages.ts +21 -4
  53. package/src/table/TableView.tsx +2 -1
  54. package/src/testing/fake.ts +29 -14
  55. package/dist/locales/en.json +0 -594
  56. package/dist/locales/nl.json +0 -594
  57. package/src/locales/en.json +0 -594
  58. package/src/locales/nl.json +0 -594
package/README.md CHANGED
@@ -107,7 +107,7 @@ The words live in the `database` namespace, in English and Dutch. `DatabaseProvi
107
107
 
108
108
  ## The helper binary
109
109
 
110
- The helper is a Rust program that holds the database drivers, so a crashing driver cannot take the backend down. The install brings a prebuilt one: `@adecore/database` lists a package per platform as an optional dependency (`@adecore/database-darwin-arm64` for Apple silicon, `-linux-x64`, `-linux-arm64` and `-win32-x64`), and the package manager installs the one that fits the machine. `helperPath()` returns its path, or `null` when the platform has no package or optional dependencies were left out.
110
+ The helper is a Rust program that holds the database drivers, so a crashing driver cannot take the backend down. The install brings a prebuilt one: `@adecore/database` lists a package per platform as an optional dependency (`@adecore/database-darwin-arm64` for Apple silicon, `-linux-x64`, `-linux-arm64` and `-win32-x64`), and the package manager installs the one that fits the machine. `helperPath()` returns its path, or `null` when the platform has no package or optional dependencies were left out. `ADECORE_DATABASE_HELPER` overrides it with the path of another build.
111
111
 
112
112
  ```ts
113
113
  import { createDatabaseHost, helperPath, spawnHelper } from '@adecore/database/host';
@@ -127,7 +127,7 @@ The Linux binaries link against glibc 2.35 or newer. An app that wants its own b
127
127
  cargo build --release --locked --manifest-path helper/Cargo.toml
128
128
  ```
129
129
 
130
- The binary is `helper/target/release/adecore-database`.
130
+ The binary is `helper/target/release/adecore-database`. When the package runs from the `src` of this checkout, such as through `bun link`, and no platform package has a binary, `helperPath()` returns that build.
131
131
 
132
132
  ### In an Electron app
133
133
 
@@ -35,6 +35,8 @@ const raceAbort = (promise, signal) => {
35
35
  export const createDatabaseClient = (transport, options = {}) => {
36
36
  const createId = options.createId ?? (() => crypto.randomUUID());
37
37
  const live = new Map();
38
+ // Every session that opened on the helper and has not closed, also one a newer session replaced on its channel.
39
+ const held = new Set();
38
40
  const schemaListeners = new Set();
39
41
  const channelsOf = (connectionId) => {
40
42
  let channels = live.get(connectionId);
@@ -55,7 +57,8 @@ export const createDatabaseClient = (transport, options = {}) => {
55
57
  }
56
58
  }
57
59
  };
58
- const send = async (method, params, signal) => {
60
+ /* With `settles`, an abort sends the cancel and waits for the answer, which can still carry what ran. */
61
+ const send = async (method, params, signal, settles = false) => {
59
62
  if (signal?.aborted) {
60
63
  throw cancelledError();
61
64
  }
@@ -64,8 +67,15 @@ export const createDatabaseClient = (transport, options = {}) => {
64
67
  return new Promise((resolve, reject) => {
65
68
  const onAbort = () => {
66
69
  const cancel = { id: createId(), method: 'cancel', params: { request: id } };
67
- new Promise((done) => done(transport(cancel))).catch(() => { });
68
- reject(cancelledError());
70
+ const cancelling = new Promise((done) => done(transport(cancel)));
71
+ if (settles) {
72
+ // Without a cancel on its way, nothing tells the request to stop, so its answer may never come.
73
+ cancelling.catch(() => reject(cancelledError()));
74
+ }
75
+ else {
76
+ cancelling.catch(() => { });
77
+ reject(cancelledError());
78
+ }
69
79
  };
70
80
  signal?.addEventListener('abort', onAbort, { once: true });
71
81
  new Promise((done) => done(transport(request)))
@@ -97,6 +107,7 @@ export const createDatabaseClient = (transport, options = {}) => {
97
107
  return Promise.resolve(opened);
98
108
  }
99
109
  track();
110
+ held.add(created);
100
111
  opening ??= send('open', { connection: connection.config })
101
112
  .then((result) => (opened = result))
102
113
  .finally(() => {
@@ -105,14 +116,14 @@ export const createDatabaseClient = (transport, options = {}) => {
105
116
  return opening;
106
117
  };
107
118
  /* Reads are safe to send twice, so they open again and retry once; a write is not, so it only drops the session. */
108
- const request = async (method, build, requestOptions, retry) => {
119
+ const request = async (method, build, requestOptions, retry, settles = false) => {
109
120
  const signal = requestOptions?.signal;
110
121
  if (signal?.aborted) {
111
122
  throw cancelledError();
112
123
  }
113
124
  const current = await raceAbort(open(), signal);
114
125
  try {
115
- return await send(method, build(current.session), signal);
126
+ return await send(method, build(current.session), signal, settles);
116
127
  }
117
128
  catch (error) {
118
129
  if (!isUnknownSession(error)) {
@@ -122,7 +133,7 @@ export const createDatabaseClient = (transport, options = {}) => {
122
133
  opened = null;
123
134
  }
124
135
  if (retry) {
125
- return request(method, build, requestOptions, false);
136
+ return request(method, build, requestOptions, false, settles);
126
137
  }
127
138
  throw error;
128
139
  }
@@ -135,6 +146,7 @@ export const createDatabaseClient = (transport, options = {}) => {
135
146
  live.delete(connection.id);
136
147
  }
137
148
  }
149
+ held.delete(created);
138
150
  const current = opened ?? (opening === null ? null : await opening.catch(() => null));
139
151
  opened = null;
140
152
  if (current !== null) {
@@ -172,7 +184,7 @@ export const createDatabaseClient = (transport, options = {}) => {
172
184
  },
173
185
  async execute(sql, executeOptions) {
174
186
  const { signal: _signal, ...rest } = executeOptions ?? {};
175
- const result = await request('execute', (id) => ({ session: id, sql, ...rest }), executeOptions, false);
187
+ const result = await request('execute', (id) => ({ session: id, sql, ...rest }), executeOptions, false, true);
176
188
  if (result.results.some((item) => item.kind !== 'error' && changesSchema(item.sql))) {
177
189
  notifySchemaChange({ connectionId: connection.id, ...(rest.schema === undefined ? {} : { schema: rest.schema }) });
178
190
  }
@@ -231,14 +243,22 @@ export const createDatabaseClient = (transport, options = {}) => {
231
243
  return created.session;
232
244
  },
233
245
  async disconnect(connectionId) {
234
- const existing = [...(live.get(connectionId)?.values() ?? [])];
246
+ const existing = new Set(live.get(connectionId)?.values());
247
+ for (const entry of held) {
248
+ if (entry.session.connection.id === connectionId) {
249
+ existing.add(entry);
250
+ }
251
+ }
235
252
  live.delete(connectionId);
236
- await Promise.all(existing.map((entry) => entry.close()));
253
+ await Promise.all([...existing].map((entry) => entry.close()));
237
254
  },
238
255
  async dispose() {
239
- const all = [...live.values()].flatMap((channels) => [...channels.values()]);
256
+ const all = new Set([...live.values()].flatMap((channels) => [...channels.values()]));
257
+ for (const entry of held) {
258
+ all.add(entry);
259
+ }
240
260
  live.clear();
241
- await Promise.all(all.map((entry) => entry.close()));
261
+ await Promise.all([...all].map((entry) => entry.close()));
242
262
  }
243
263
  };
244
264
  };
@@ -19,11 +19,13 @@ export interface RowsQuery {
19
19
  readonly offset: number;
20
20
  readonly limit: number;
21
21
  readonly cellLimit?: number;
22
+ readonly maxBytes?: number;
22
23
  }
23
24
  export interface ExecuteOptions extends RequestOptions {
24
25
  readonly schema?: string;
25
26
  readonly limit?: number;
26
27
  readonly cellLimit?: number;
28
+ readonly maxBytes?: number;
27
29
  }
28
30
  export interface DatabaseSession {
29
31
  readonly connection: Connection;
@@ -51,6 +53,7 @@ export interface PageQuery {
51
53
  readonly offset: number;
52
54
  readonly limit: number;
53
55
  readonly cellLimit?: number;
56
+ readonly maxBytes?: number;
54
57
  }
55
58
  export interface ExportRequest {
56
59
  readonly source: ExportSource;
@@ -179,11 +179,8 @@ export function QueryConsole({ connection, schema, onSchemaChange, value, defaul
179
179
  if (mode === 'manual' && !inTransaction) {
180
180
  setInTransaction(await session.transaction('begin', { signal: controller.signal }));
181
181
  }
182
+ // A cancel that stopped the run midway still answers what ran and whether a transaction stayed open.
182
183
  const executed = await session.execute(target, { schema: activeSchema, limit: PAGE_SIZE, signal: controller.signal });
183
- if (controller.signal.aborted) {
184
- setRun({ status: 'cancelled' });
185
- return;
186
- }
187
184
  setInTransaction(executed.inTransaction);
188
185
  setTab(0);
189
186
  setPages({});
@@ -246,7 +243,7 @@ export function QueryConsole({ connection, schema, onSchemaChange, value, defaul
246
243
  setMode(next);
247
244
  }
248
245
  };
249
- const loadPage = async (index, result, offset) => {
246
+ const loadPage = async (index, result, offset, before) => {
250
247
  if (session === null) {
251
248
  return;
252
249
  }
@@ -256,7 +253,7 @@ export function QueryConsole({ connection, schema, onSchemaChange, value, defaul
256
253
  try {
257
254
  const rows = await session.page(result.sql, { schema: activeSchema, offset, limit: PAGE_SIZE });
258
255
  if (generation.current === mine) {
259
- setPages((current) => ({ ...current, [index]: { offset, rows } }));
256
+ setPages((current) => ({ ...current, [index]: { offset, rows, before } }));
260
257
  }
261
258
  }
262
259
  catch (error) {
@@ -291,12 +288,13 @@ export function QueryConsole({ connection, schema, onSchemaChange, value, defaul
291
288
  const page = pages[index];
292
289
  const shown = base?.kind === 'rows' && page !== undefined ? { kind: 'rows', sql: base.sql, ...page.rows } : base;
293
290
  const offset = page?.offset ?? 0;
291
+ const before = page?.before ?? [];
294
292
  const pager = base?.kind === 'rows' && shown?.kind === 'rows' && engine !== undefined && isPageable(base.sql, engine) && (shown.hasMore || offset > 0)
295
293
  ? {
296
- page: offset / PAGE_SIZE + 1,
294
+ page: before.length + 1,
297
295
  loading: paging,
298
- onPrevious: () => void loadPage(index, base, Math.max(0, offset - PAGE_SIZE)),
299
- onNext: () => void loadPage(index, base, offset + PAGE_SIZE)
296
+ onPrevious: () => void loadPage(index, base, before.at(-1) ?? 0, before.slice(0, -1)),
297
+ onNext: () => void loadPage(index, base, offset + shown.rows.length, [...before, offset])
300
298
  }
301
299
  : undefined;
302
300
  const exportBase = files !== undefined && base?.kind === 'rows' ? base : undefined;
@@ -312,7 +310,7 @@ export function QueryConsole({ connection, schema, onSchemaChange, value, defaul
312
310
  placeholder: t('console.placeholder'),
313
311
  autoFocus
314
312
  };
315
- return (_jsxs("div", { ref: ref, className: clsx('flex min-h-0 flex-col bg-surface text-text', className), children: [_jsxs("div", { className: "flex min-h-0 flex-1", children: [_jsxs("div", { className: "flex min-w-0 flex-1 flex-col", children: [_jsxs("div", { className: "flex shrink-0 flex-wrap items-center gap-2 border-b border-border px-3 py-2", children: [session !== null && (_jsxs(_Fragment, { children: [_jsx(RunButtons, { running: run.status === 'running', disabled: blank || busy, onCancel: () => running.current?.abort(), onRun: (scope) => requestRun(targetOf(scope)) }), _jsx(TransactionControls, { mode: mode, onModeChange: changeMode, open: inTransaction, busy: busy, onCommit: () => void settle('commit'), onRollback: () => void settle('rollback') })] })), _jsxs("div", { className: "ml-auto flex items-center gap-2", children: [toolbarEnd, session !== null && schemas.length > 0 && (_jsx(SchemaPicker, { schemas: schemas, value: activeSchema ?? null, disabled: busy, onValueChange: onSchemaChange ?? setPickedSchema })), session !== null && (_jsxs(Button, { variant: "secondary", size: "sm", "aria-pressed": historyOpen, className: "aria-pressed:bg-surface-active aria-pressed:text-text", onClick: () => setHistoryOpen(!historyOpen), children: [_jsx(Icon, { icon: History, size: 12 }), t('console.history.title')] }))] })] }), _jsx("div", { className: clsx('flex min-h-0 flex-1 flex-col', renderEditor === undefined && 'p-3'), children: renderEditor === undefined ? _jsx(SqlEditor, { ...editorProps }) : _jsx(AppEditor, { render: renderEditor, ...editorProps }) })] }), session !== null && historyOpen && _jsx(HistoryPanel, { entries: history.entries, onPick: pickHistory, onClear: history.clear })] }), session !== null && notice !== null && (_jsx(Banner, { icon: notice.tone === 'error' ? CircleAlert : Check, tone: notice.tone === 'error' ? 'error' : 'neutral', message: notice.description ?? notice.title, className: "shrink-0 pb-2", children: _jsx(Button, { size: "xs", onClick: () => setNotice(null), children: t('console.dismiss') }) })), session !== null && resultsOpen && (_jsxs("section", { ref: resultsPane, "aria-label": t('console.results'), className: "relative flex min-h-24 shrink-0 flex-col border-t border-border", style: { height: resultsHeight ?? '50%', maxHeight: `calc(100% - ${MIN_EDITOR_HEIGHT}px)` }, children: [_jsx(ColumnResizeHandle, { from: "bottom", onPointerDown: startResize }), _jsx(Tabs.Root, { value: index, onValueChange: (next) => setTab(Number(next)), children: _jsx(Tabs.List, { "aria-label": t('console.results'), className: "shrink-0 pr-1.5 pl-3", end: _jsx(IconButton, { icon: X, size: "sm", label: t('console.closeResults'), onClick: () => setResultsOpen(false) }), children: results.map((result, position) => (_jsx(Tabs.Tab, { value: position, children: statementLabel(result.sql) }, position))) }) }), run.status === 'failed' && _jsx(Banner, { icon: CircleAlert, tone: "error", message: run.message, className: "shrink-0 pt-2" }), run.status === 'cancelled' && _jsx(Banner, { icon: CircleAlert, tone: "neutral", message: t('console.cancelled'), className: "shrink-0 pt-2" }), shown !== undefined && (_jsx(StatementResultView, { result: shown, offset: offset, pager: pager, onExport: exportBase === undefined ? undefined : (format) => void exportResult(exportBase, format), exporting: exporting, engine: engine, valuePanelOpen: panelOpen, onValuePanelOpenChange: setPanelOpen, keys: keys, onFollowReference: act === undefined || connection === undefined
313
+ return (_jsxs("div", { ref: ref, className: clsx('flex min-h-0 flex-col bg-surface text-text', className), children: [_jsxs("div", { className: "flex min-h-0 flex-1", children: [_jsxs("div", { className: "flex min-w-0 flex-1 flex-col", children: [_jsxs("div", { className: "flex shrink-0 flex-wrap items-center gap-2 border-b border-border px-3 py-2", children: [session !== null && (_jsxs(_Fragment, { children: [_jsx(RunButtons, { running: run.status === 'running', disabled: blank || busy, onCancel: () => running.current?.abort(), onRun: (scope) => requestRun(targetOf(scope)) }), _jsx(TransactionControls, { mode: mode, onModeChange: changeMode, open: inTransaction, busy: busy, onCommit: () => void settle('commit'), onRollback: () => void settle('rollback') })] })), _jsxs("div", { className: "ml-auto flex items-center gap-2", children: [toolbarEnd, session !== null && schemas.length > 0 && (_jsx(SchemaPicker, { schemas: schemas, value: activeSchema ?? null, disabled: busy, onValueChange: onSchemaChange ?? setPickedSchema })), session !== null && (_jsxs(Button, { variant: "secondary", size: "sm", "aria-pressed": historyOpen, className: "aria-pressed:bg-surface-active aria-pressed:text-text", onClick: () => setHistoryOpen(!historyOpen), children: [_jsx(Icon, { icon: History, size: 12 }), t('console.history.title')] }))] })] }), _jsx("div", { className: clsx('flex min-h-0 flex-1 flex-col', renderEditor === undefined && 'p-3'), children: renderEditor === undefined ? _jsx(SqlEditor, { ...editorProps }) : _jsx(AppEditor, { render: renderEditor, ...editorProps }) })] }), session !== null && historyOpen && _jsx(HistoryPanel, { entries: history.entries, onPick: pickHistory, onClear: history.clear })] }), session !== null && notice !== null && (_jsx(Banner, { icon: notice.tone === 'error' ? CircleAlert : Check, tone: notice.tone === 'error' ? 'error' : 'neutral', message: notice.description ?? notice.title, className: "shrink-0 pb-2", children: _jsx(Button, { size: "xs", onClick: () => setNotice(null), children: t('console.dismiss') }) })), session !== null && resultsOpen && (_jsxs("section", { ref: resultsPane, "aria-label": t('console.results'), className: "relative flex min-h-24 shrink-0 flex-col border-t border-border", style: { height: resultsHeight ?? '50%', maxHeight: `calc(100% - ${MIN_EDITOR_HEIGHT}px)` }, children: [_jsx(ColumnResizeHandle, { from: "bottom", onPointerDown: startResize }), _jsx(Tabs.Root, { value: index, onValueChange: (next) => setTab(Number(next)), children: _jsx(Tabs.List, { "aria-label": t('console.results'), className: "shrink-0 pr-1.5 pl-3", end: _jsx(IconButton, { icon: X, size: "sm", label: t('console.closeResults'), onClick: () => setResultsOpen(false) }), children: results.map((result, position) => (_jsx(Tabs.Tab, { value: position, children: statementLabel(result.sql) }, position))) }) }), run.status === 'failed' && _jsx(Banner, { icon: CircleAlert, tone: "error", message: run.message, className: "shrink-0 pt-2" }), run.status === 'cancelled' && _jsx(Banner, { icon: CircleAlert, tone: "neutral", message: t('console.cancelled'), className: "shrink-0 pt-2" }), shown !== undefined && (_jsx(StatementResultView, { result: shown, offset: offset, pager: pager, onExport: exportBase === undefined ? undefined : (format) => void exportResult(exportBase, format), exporting: exporting, engine: engine, valuePanelOpen: panelOpen, onValuePanelOpenChange: setPanelOpen, keys: keys, onNotice: notify, onFollowReference: act === undefined || connection === undefined
316
314
  ? undefined
317
315
  : (reference) => act({
318
316
  kind: 'open-table',
@@ -1,10 +1,10 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useEffect, useImperativeHandle, useRef } from 'react';
3
3
  import clsx from 'clsx';
4
- import { isApplePlatform, matchesShortcut, TextArea } from '@adecore/ui';
4
+ import { isApplePlatform, TextArea } from '@adecore/ui';
5
5
  import { CODE_TEXT } from '../code-text.js';
6
6
  import { applyEdit, indentEdit, newlineEdit, outdentEdit } from './editing.js';
7
- import { RUN_ALL_SHORTCUT, RUN_SHORTCUT } from './shortcuts.js';
7
+ import { runScopeOf } from './shortcuts.js';
8
8
  /*
9
9
  * Where the SQL is typed when the app brings no editor of its own: a plain text area that fills the
10
10
  * height it gets, with the same props an editor of the app receives.
@@ -40,15 +40,10 @@ export function SqlEditor({ ref, value, onValueChange, run, label, placeholder,
40
40
  onValueChange(applyEdit(before, edit));
41
41
  };
42
42
  const handleKeyDown = (event) => {
43
- const apple = isApplePlatform();
44
- if (matchesShortcut(RUN_ALL_SHORTCUT, event, apple)) {
43
+ const scope = runScopeOf(event, isApplePlatform());
44
+ if (scope !== null) {
45
45
  event.preventDefault();
46
- run('all');
47
- return;
48
- }
49
- if (matchesShortcut(RUN_SHORTCUT, event, apple)) {
50
- event.preventDefault();
51
- run('selection-or-statement');
46
+ run(scope);
52
47
  return;
53
48
  }
54
49
  if (event.nativeEvent.isComposing || event.metaKey || event.ctrlKey || event.altKey) {
@@ -1,3 +1,4 @@
1
+ import type { DatabaseNotice } from '../actions.ts';
1
2
  import type { Engine, FileFormat, StatementResult } from '../protocol/index.ts';
2
3
  import type { Reference } from '../table/references.ts';
3
4
  import type { ResultKeys } from './result-keys.ts';
@@ -18,5 +19,6 @@ export interface StatementResultViewProps {
18
19
  onValuePanelOpenChange?(open: boolean): void;
19
20
  keys?: ResultKeys | null;
20
21
  onFollowReference?(reference: Reference): void;
22
+ onNotice?(notice: DatabaseNotice): void;
21
23
  }
22
- export declare function StatementResultView({ result, offset, pager, onExport, exporting, engine, valuePanelOpen, onValuePanelOpenChange, keys, onFollowReference }: StatementResultViewProps): import("react").JSX.Element;
24
+ export declare function StatementResultView({ result, offset, pager, onExport, exporting, engine, valuePanelOpen, onValuePanelOpenChange, keys, onFollowReference, onNotice }: StatementResultViewProps): import("react").JSX.Element;
@@ -12,12 +12,15 @@ import { ValuePanel } from '../value/ValuePanel.js';
12
12
  const RESULT_TABLE = 'result';
13
13
  const EXPORT_FORMATS = ['csv', 'tsv', 'json', 'sql'];
14
14
  /* One statement's outcome: a result set in a read-only grid, a count of affected rows, or the error the server gave. */
15
- export function StatementResultView({ result, offset = 0, pager, onExport, exporting = false, engine, valuePanelOpen = false, onValuePanelOpenChange, keys = null, onFollowReference }) {
15
+ export function StatementResultView({ result, offset = 0, pager, onExport, exporting = false, engine, valuePanelOpen = false, onValuePanelOpenChange, keys = null, onFollowReference, onNotice }) {
16
16
  const { t } = useTranslation('database');
17
17
  const [focus, setFocus] = useState(null);
18
18
  const rows = useMemo(() => (result.kind === 'rows' ? result.rows.map((cells, index) => ({ key: `row:${index}`, number: offset + index + 1, cells })) : []), [result, offset]);
19
19
  const sqlTarget = useMemo(() => (engine === undefined ? undefined : { engine, table: RESULT_TABLE }), [engine]);
20
20
  const elapsed = t('console.elapsed', { value: formatDecimal(result.elapsedMs) });
21
+ if (result.kind === 'error' && result.error.code === 'cancelled') {
22
+ return (_jsxs("div", { className: "flex min-h-0 flex-1 flex-col gap-3 p-3", children: [_jsx(Banner, { icon: CircleAlert, tone: "neutral", className: "min-w-0", message: t('console.cancelled') }), _jsx("p", { className: "px-1 text-xs text-text-muted tabular-nums", children: elapsed })] }));
23
+ }
21
24
  if (result.kind === 'error') {
22
25
  return (_jsxs("div", { className: "flex min-h-0 flex-1 flex-col gap-3 p-3", children: [_jsx(Banner, { icon: CircleAlert, tone: "error", className: "min-w-0", message: _jsxs(_Fragment, { children: [result.error.message, result.error.sqlState !== undefined && (_jsx("span", { className: "ml-2 font-mono text-xs text-text-muted", children: t('console.sqlState', { state: result.error.sqlState }) }))] }) }), _jsx("p", { className: "px-1 text-xs text-text-muted tabular-nums", children: elapsed })] }));
23
26
  }
@@ -39,7 +42,7 @@ export function StatementResultView({ result, offset = 0, pager, onExport, expor
39
42
  const referenceMenu = (context) => follow === undefined || context.cell === null || keys?.isReference(context.cell.column) !== true ? null : (_jsxs(ContextMenu.Item, { disabled: referenceAt(context.cell) === null, onClick: () => context.cell !== null && follow(context.cell), children: [_jsx(Icon, { icon: ArrowUpRight, size: 14 }), t('table.goToReferenced')] }));
40
43
  const focusedRow = valuePanelOpen && focus !== null ? rows.find((row) => row.key === focus.rowKey) : undefined;
41
44
  const focusedCell = focus === null ? undefined : focusedRow?.cells[focus.column];
42
- return (_jsxs(_Fragment, { children: [_jsx(ValueDock, { open: valuePanelOpen, panel: _jsx(ValuePanel, { column: focus === null ? null : (result.columns[focus.column] ?? null), value: focusedCell === undefined ? undefined : previewValueOf(focusedCell), loading: false, editable: false, onClose: () => onValuePanelOpenChange?.(false), className: "min-w-0 flex-1" }), children: _jsx(DataGrid, { label: t('console.result'), columns: keys?.columns ?? result.columns, rows: rows, empty: t('console.noRows'), sqlTarget: sqlTarget, onFocusedCellChange: setFocus, onFollow: follow, canFollow: (cell) => referenceAt(cell) !== null, menu: referenceMenu }) }), _jsxs("footer", { className: "flex h-10 shrink-0 items-center gap-4 border-t border-border px-3 text-xs text-text-muted", children: [_jsx("span", { className: "text-text tabular-nums", children: offset > 0 || pager !== undefined
45
+ return (_jsxs(_Fragment, { children: [_jsx(ValueDock, { open: valuePanelOpen, panel: _jsx(ValuePanel, { column: focus === null ? null : (result.columns[focus.column] ?? null), value: focusedCell === undefined ? undefined : previewValueOf(focusedCell), loading: false, editable: false, onClose: () => onValuePanelOpenChange?.(false), className: "min-w-0 flex-1" }), children: _jsx(DataGrid, { label: t('console.result'), columns: keys?.columns ?? result.columns, rows: rows, empty: t('console.noRows'), sqlTarget: sqlTarget, onFocusedCellChange: setFocus, onFollow: follow, canFollow: (cell) => referenceAt(cell) !== null, menu: referenceMenu, onNotice: onNotice }) }), _jsxs("footer", { className: "flex h-10 shrink-0 items-center gap-4 border-t border-border px-3 text-xs text-text-muted", children: [_jsx("span", { className: "text-text tabular-nums", children: offset > 0 || pager !== undefined
43
46
  ? t('console.rowRange', { from: formatNumber(offset + Math.min(1, rows.length)), to: formatNumber(offset + rows.length) })
44
47
  : t('console.rows', { rows: formatNumber(result.rows.length) }) }), pager === undefined && result.hasMore && _jsx("span", { children: t('console.hasMore') }), _jsx("span", { className: "tabular-nums", children: elapsed }), _jsxs("span", { className: "ml-auto flex items-center gap-1", children: [pager !== undefined && (_jsxs(_Fragment, { children: [pager.loading && _jsx(Spinner, { size: 14, label: t('console.loading') }), _jsx(IconButton, { icon: ChevronLeft, size: "sm", label: t('console.previousPage'), disabled: pager.page <= 1 || pager.loading, onClick: pager.onPrevious }), _jsx("span", { className: "min-w-12 text-center tabular-nums", children: t('console.page', { page: formatNumber(pager.page) }) }), _jsx(IconButton, { icon: ChevronRight, size: "sm", label: t('console.nextPage'), disabled: !result.hasMore || pager.loading, onClick: pager.onNext })] })), _jsxs(ButtonGroup, { children: [onExport !== undefined && (_jsxs(Menu.Root, { children: [_jsx(IconButton, { icon: Download, size: "sm", label: t('console.export'), disabled: exporting, render: _jsx(Menu.Trigger, {}) }), _jsx(Menu.Popup, { children: EXPORT_FORMATS.map((format) => (_jsx(Menu.Item, { onClick: () => onExport(format), children: t('console.exportAs', { format: format.toUpperCase() }) }, format))) })] })), onValuePanelOpenChange !== undefined && (_jsx(IconButton, { icon: PanelRight, size: "sm", label: t('console.valuePanel'), "aria-pressed": valuePanelOpen, onClick: () => onValuePanelOpenChange(!valuePanelOpen) }))] })] })] })] }));
45
48
  }
@@ -1,2 +1,5 @@
1
+ import { type KeyLike } from '@adecore/ui';
2
+ import type { QueryConsoleRunScope } from './editor-slot.ts';
1
3
  export declare const RUN_SHORTCUT: import("@adecore/ui").Shortcut;
2
4
  export declare const RUN_ALL_SHORTCUT: import("@adecore/ui").Shortcut;
5
+ export declare const runScopeOf: (event: KeyLike, apple: boolean) => QueryConsoleRunScope | null;
@@ -1,3 +1,14 @@
1
- import { shortcut } from '@adecore/ui';
2
- export const RUN_SHORTCUT = shortcut('Mod+Enter');
3
- export const RUN_ALL_SHORTCUT = shortcut('Mod+Shift+Enter');
1
+ import { matchesShortcut, shortcut } from '@adecore/ui';
2
+ /* The keys the toolbar's tooltips name, which an editor binds to its two runs. */
3
+ export const RUN_SHORTCUT = /* @__PURE__ */ shortcut('Mod+Enter');
4
+ export const RUN_ALL_SHORTCUT = /* @__PURE__ */ shortcut('Mod+Shift+Enter');
5
+ /* The run a key event asks for, or null when it is neither run key. `apple` is the one `matchesShortcut` takes. */
6
+ export const runScopeOf = (event, apple) => {
7
+ if (matchesShortcut(RUN_ALL_SHORTCUT, event, apple)) {
8
+ return 'all';
9
+ }
10
+ if (matchesShortcut(RUN_SHORTCUT, event, apple)) {
11
+ return 'selection-or-statement';
12
+ }
13
+ return null;
14
+ };
@@ -1,4 +1,5 @@
1
1
  import { type ReactNode, type Ref } from 'react';
2
+ import type { DatabaseNotice } from '../actions.ts';
2
3
  import type { EditValue, Value } from '../protocol/index.ts';
3
4
  import type { SqlTarget } from '../sql.ts';
4
5
  import type { RangeBlock } from './aggregates.ts';
@@ -14,6 +15,7 @@ export interface DataGridProps {
14
15
  onSelectedKeysChange?(keys: ReadonlySet<string>): void;
15
16
  onCommit?(rowKey: string, column: number, value: EditValue): void;
16
17
  loadValue?(rowKey: string, column: number): Promise<Value | undefined>;
18
+ onNotice?(notice: DatabaseNotice): void;
17
19
  sorts?: readonly GridSort[];
18
20
  onSortsChange?(sorts: readonly GridSort[]): void;
19
21
  onFocusedCellChange?(cell: FocusedCell | null): void;
@@ -33,4 +35,4 @@ export interface DataGridProps {
33
35
  className?: string;
34
36
  ref?: Ref<HTMLDivElement>;
35
37
  }
36
- export declare function DataGrid({ columns, rows, label, editable, selectedKeys, onSelectedKeysChange, onCommit, loadValue, sorts, onSortsChange, onFocusedCellChange, onDeleteSelected, sqlTarget, queryMenu, menu, onDuplicateRows, onFollow, canFollow, onRangeChange, initialLayout, onLayoutChange, focusRequest, columnRequest, empty, className, ref }: DataGridProps): import("react").JSX.Element;
38
+ export declare function DataGrid({ columns, rows, label, editable, selectedKeys, onSelectedKeysChange, onCommit, loadValue, onNotice, sorts, onSortsChange, onFocusedCellChange, onDeleteSelected, sqlTarget, queryMenu, menu, onDuplicateRows, onFollow, canFollow, onRangeChange, initialLayout, onLayoutChange, focusRequest, columnRequest, empty, className, ref }: DataGridProps): import("react").JSX.Element;
@@ -4,6 +4,7 @@ import clsx from 'clsx';
4
4
  import { Ban, Copy, RotateCcw } from 'lucide-react';
5
5
  import { useTranslation } from 'react-i18next';
6
6
  import { ContextMenu, copyText, EDIT_SHORTCUTS, Icon, isApplePlatform, isModHeld, Kbd, useContentSize } from '@adecore/ui';
7
+ import { formatNumber } from '@adecore/ui/format';
7
8
  import { assignRef } from '../assign-ref.js';
8
9
  import { useNumberNotation } from '../client-context.js';
9
10
  import { usePopupPress } from '../use-popup-press.js';
@@ -14,7 +15,7 @@ import { NO_COLUMN_SELECTION, selectColumn, shownSelection } from './column-sele
14
15
  import { displayOrder, hideColumn, pinnedCount, showAllColumns, togglePin } from './column-view.js';
15
16
  import { COPY_FORMATS, formatCopy } from './copy-formats.js';
16
17
  import { CopyAsMenu } from './CopyAsMenu.js';
17
- import { cellView, copyTextOf, isPreview } from './display.js';
18
+ import { columnNotation, cellView, copyTextOf, isPreview } from './display.js';
18
19
  import { draftOf, parseDraft } from './edit-value.js';
19
20
  import { parseEnumType } from './enum-type.js';
20
21
  import { GridHeaderCell } from './GridHeaderCell.js';
@@ -24,18 +25,19 @@ import { clampColumnWidth, columnOffsets, estimateColumnWidth, fitColumnWidth, F
24
25
  import { moveFocus } from './navigation.js';
25
26
  import { selectRow } from './row-selection.js';
26
27
  import { sortOnly, sortStateOf } from './sort.js';
28
+ import { loadWholeCells } from './whole-cells.js';
27
29
  const NO_KEYS = new Set();
28
30
  const NO_SORTS = [];
29
31
  const signatureOf = (columns) => columns.map((column) => column.name).join('\u0000');
30
32
  const textOf = (view) => (view.suffix === undefined ? view.text : `${view.text} ${view.suffix}`);
31
- const estimateWidths = (columns, rows, notation) => columns.map((column, index) => estimateColumnWidth(column.name, rows.slice(0, 50).map((row) => textOf(cellView(row.cells[index] ?? null, column.kind, notation))), column.primaryKey === true));
33
+ const estimateWidths = (columns, rows, notation) => columns.map((column, index) => estimateColumnWidth(column.name, rows.slice(0, 50).map((row) => textOf(cellView(row.cells[index] ?? null, column.kind, columnNotation(column, notation)))), column.primaryKey === true));
32
34
  /*
33
35
  * The table of the table view and of the console. Only the rows in view are drawn, at a fixed
34
36
  * height, inside a box as tall as all of them; the header and the row-number gutter stick to the edges
35
37
  * of the scroll box. Focus stays on that box and points at the cell with `aria-activedescendant`, so
36
38
  * a cell scrolled out of the window loses nothing.
37
39
  */
38
- export function DataGrid({ columns, rows, label, editable = false, selectedKeys, onSelectedKeysChange, onCommit, loadValue, sorts, onSortsChange, onFocusedCellChange, onDeleteSelected, sqlTarget, queryMenu, menu, onDuplicateRows, onFollow, canFollow, onRangeChange, initialLayout, onLayoutChange, focusRequest, columnRequest, empty, className, ref }) {
40
+ export function DataGrid({ columns, rows, label, editable = false, selectedKeys, onSelectedKeysChange, onCommit, loadValue, onNotice, sorts, onSortsChange, onFocusedCellChange, onDeleteSelected, sqlTarget, queryMenu, menu, onDuplicateRows, onFollow, canFollow, onRangeChange, initialLayout, onLayoutChange, focusRequest, columnRequest, empty, className, ref }) {
39
41
  const { t } = useTranslation('database');
40
42
  const gridId = useId();
41
43
  const scroller = useRef(null);
@@ -290,36 +292,47 @@ export function DataGrid({ columns, rows, label, editable = false, selectedKeys,
290
292
  onCommit?.(row.key, position.column, value);
291
293
  }
292
294
  };
293
- /* The cells of the range or of the picked columns, as the columns and rows a copy writes. */
294
- const blockOf = () => {
295
+ /* The cells from row `top` to `bottom` in the given columns, with the whole value of every preview `loadValue` can fetch. */
296
+ const wholeCellsOf = async (top, bottom, indexes) => {
297
+ const shown = rows.slice(top, bottom + 1);
298
+ const whole = await loadWholeCells(shown.map((row) => indexes.map((index) => row.cells[index] ?? null)), loadValue === undefined ? undefined : (row, column) => loadValue(shown[row].key, indexes[column]));
299
+ if (whole.previews > 0) {
300
+ onNotice?.({
301
+ tone: 'error',
302
+ title: t('grid.copyPreviews.title', { count: whole.previews, formatted: formatNumber(whole.previews) }),
303
+ description: t(whole.tooLarge ? 'grid.copyPreviews.tooLarge' : 'grid.copyPreviews.unavailable')
304
+ });
305
+ }
306
+ return whole.rows;
307
+ };
308
+ const copyAs = async (format) => {
295
309
  if (block === null) {
296
- return null;
310
+ return;
297
311
  }
298
- return {
299
- columns: block.columns.map((index) => ({ name: columns[index].name, kind: columns[index].kind })),
300
- rows: rows.slice(block.top, block.bottom + 1).map((row) => block.columns.map((index) => row.cells[index] ?? null)),
312
+ const picked = block.columns;
313
+ const cells = await wholeCellsOf(block.top, block.bottom, picked);
314
+ await copyText(formatCopy(format, {
315
+ columns: picked.map((index) => ({ name: columns[index].name, kind: columns[index].kind })),
316
+ rows: cells,
301
317
  target: sqlTarget
302
- };
318
+ }));
303
319
  };
304
- const copyAs = (format) => {
305
- const block = blockOf();
306
- if (block !== null) {
307
- copyText(formatCopy(format, block));
308
- }
320
+ const copyOne = async (row, column) => {
321
+ const [cells] = await wholeCellsOf(row, row, [column]);
322
+ await copyText(copyTextOf(cells?.[0] ?? null));
309
323
  };
310
324
  /* One cell copies as it reads; a block copies as TSV, which a spreadsheet pastes into cells. */
311
325
  const copyRange = () => {
312
326
  if (block !== null && block.top === block.bottom && block.columns.length === 1) {
313
- copyText(copyTextOf(rows[block.top]?.cells[block.columns[0]] ?? null));
327
+ void copyOne(block.top, block.columns[0]);
314
328
  }
315
329
  else {
316
- copyAs('tsv');
330
+ void copyAs('tsv');
317
331
  }
318
332
  };
319
333
  const copyCell = () => {
320
- const row = menuTarget === null ? undefined : rows[menuTarget.row];
321
- if (row !== undefined && menuTarget?.column != null) {
322
- copyText(copyTextOf(row.cells[menuTarget.column] ?? null));
334
+ if (menuTarget !== null && menuTarget.column !== null && rows[menuTarget.row] !== undefined) {
335
+ void copyOne(menuTarget.row, menuTarget.column);
323
336
  }
324
337
  };
325
338
  const selectAll = () => {
@@ -431,7 +444,7 @@ export function DataGrid({ columns, rows, label, editable = false, selectedKeys,
431
444
  const fitColumn = (index) => {
432
445
  const column = columns[index];
433
446
  if (column !== undefined) {
434
- resizeColumn(index, fitColumnWidth(column.name, rows.map((row) => textOf(cellView(row.cells[index] ?? null, column.kind, notation))), column.primaryKey === true));
447
+ resizeColumn(index, fitColumnWidth(column.name, rows.map((row) => textOf(cellView(row.cells[index] ?? null, column.kind, columnNotation(column, notation)))), column.primaryKey === true));
435
448
  }
436
449
  };
437
450
  const changeView = (change) => {
@@ -507,5 +520,5 @@ export function DataGrid({ columns, rows, label, editable = false, selectedKeys,
507
520
  rowKeys: selection.has(menuRow.key) ? rows.filter((row) => selection.has(row.key)).map((row) => row.key) : [menuRow.key],
508
521
  cell: menuTarget.column === null ? null : { rowKey: menuRow.key, column: menuTarget.column }
509
522
  };
510
- return (_jsx("div", { ref: attach, role: "grid", "aria-label": label, "aria-rowcount": rows.length + 1, "aria-colcount": order.length + 1, "aria-activedescendant": pickedColumns.length > 0 ? headerId(pickedColumns[0]) : focusCell === null ? undefined : cellId(focusCell.row, focusCell.column), tabIndex: 0, className: clsx('group/grid relative min-h-0 flex-1 overflow-auto bg-surface text-text outline-none', className), onKeyDown: handleKeyDown, onScroll: (event) => setScrolledBlock(scrollBlock(event.currentTarget.scrollTop - HEADER_HEIGHT)), children: _jsxs("div", { className: "relative min-w-full", style: { width: totalWidth }, children: [_jsxs("div", { role: "row", "aria-rowindex": 1, className: "sticky top-0 z-20 flex w-full border-b border-border bg-surface", style: { height: HEADER_HEIGHT }, children: [_jsxs("div", { className: PINNED_GROUP, children: [_jsx("div", { className: "shrink-0 border-r border-border bg-surface bg-clip-border", style: { width: gutter } }), headerCells.slice(0, pinnedShown)] }), headerCells.slice(pinnedShown)] }), _jsxs(ContextMenu.Root, { children: [_jsx(ContextMenu.Trigger, { role: "rowgroup", className: "relative", style: { height: rows.length * ROW_HEIGHT }, children: Array.from({ length: range.end - range.start }, (_, offset) => renderRow(range.start + offset)) }), _jsxs(ContextMenu.Popup, { children: [_jsxs(ContextMenu.Item, { disabled: menuCell === null, onClick: copyCell, children: [_jsx(Icon, { icon: Copy, size: 14 }), t('grid.copyValue'), _jsx(Kbd, { shortcut: EDIT_SHORTCUTS.copy })] }), _jsx(CopyAsMenu, { disabled: block === null, formats: sqlTarget === undefined ? COPY_FORMATS.filter((format) => format !== 'sql') : COPY_FORMATS, onCopy: copyAs }), menuContext !== null && menuCell !== null && queryMenu !== undefined && (_jsxs(_Fragment, { children: [_jsx(ContextMenu.Separator, {}), queryMenu(menuContext)] })), _jsx(ContextMenu.Separator, {}), editable && menuCell !== null && (_jsxs(_Fragment, { children: [_jsxs(ContextMenu.Item, { disabled: !settable || !nullable, onClick: () => commitValue(null), children: [_jsx(Icon, { icon: Ban, size: 14 }), t('grid.setNull')] }), hasDefault && (_jsxs(ContextMenu.Item, { disabled: !settable, onClick: () => commitValue({ kind: 'default' }), children: [_jsx(Icon, { icon: RotateCcw, size: 14 }), t('grid.setDefault')] }))] })), menuContext !== null && menu?.(menuContext)] })] }), rows.length === 0 && empty !== undefined && _jsx("div", { className: "sticky left-0 w-fit px-4 py-6 text-xs text-text-muted", children: empty })] }) }));
523
+ return (_jsx("div", { ref: attach, role: "grid", "aria-label": label, "aria-rowcount": rows.length + 1, "aria-colcount": order.length + 1, "aria-activedescendant": pickedColumns.length > 0 ? headerId(pickedColumns[0]) : focusCell === null ? undefined : cellId(focusCell.row, focusCell.column), tabIndex: 0, className: clsx('group/grid relative min-h-0 flex-1 overflow-auto bg-surface text-text outline-none', className), onKeyDown: handleKeyDown, onScroll: (event) => setScrolledBlock(scrollBlock(event.currentTarget.scrollTop - HEADER_HEIGHT)), children: _jsxs("div", { className: "relative min-w-full", style: { width: totalWidth }, children: [_jsxs("div", { role: "row", "aria-rowindex": 1, className: "sticky top-0 z-20 flex w-full border-b border-border bg-surface", style: { height: HEADER_HEIGHT }, children: [_jsxs("div", { className: PINNED_GROUP, children: [_jsx("div", { className: "shrink-0 border-r border-border bg-surface bg-clip-border", style: { width: gutter } }), headerCells.slice(0, pinnedShown)] }), headerCells.slice(pinnedShown)] }), _jsxs(ContextMenu.Root, { children: [_jsx(ContextMenu.Trigger, { role: "rowgroup", className: "relative", style: { height: rows.length * ROW_HEIGHT }, children: Array.from({ length: range.end - range.start }, (_, offset) => renderRow(range.start + offset)) }), _jsxs(ContextMenu.Popup, { children: [_jsxs(ContextMenu.Item, { disabled: menuCell === null, onClick: copyCell, children: [_jsx(Icon, { icon: Copy, size: 14 }), t('grid.copyValue'), _jsx(Kbd, { shortcut: EDIT_SHORTCUTS.copy })] }), _jsx(CopyAsMenu, { disabled: block === null, formats: sqlTarget === undefined ? COPY_FORMATS.filter((format) => format !== 'sql') : COPY_FORMATS, onCopy: (format) => void copyAs(format) }), menuContext !== null && menuCell !== null && queryMenu !== undefined && (_jsxs(_Fragment, { children: [_jsx(ContextMenu.Separator, {}), queryMenu(menuContext)] })), _jsx(ContextMenu.Separator, {}), editable && menuCell !== null && (_jsxs(_Fragment, { children: [_jsxs(ContextMenu.Item, { disabled: !settable || !nullable, onClick: () => commitValue(null), children: [_jsx(Icon, { icon: Ban, size: 14 }), t('grid.setNull')] }), hasDefault && (_jsxs(ContextMenu.Item, { disabled: !settable, onClick: () => commitValue({ kind: 'default' }), children: [_jsx(Icon, { icon: RotateCcw, size: 14 }), t('grid.setDefault')] }))] })), menuContext !== null && menu?.(menuContext)] })] }), rows.length === 0 && empty !== undefined && _jsx("div", { className: "sticky left-0 w-fit px-4 py-6 text-xs text-text-muted", children: empty })] }) }));
511
524
  }
@@ -6,7 +6,7 @@ import { formatNumber } from '@adecore/ui/format';
6
6
  import { CODE_TEXT } from '../code-text.js';
7
7
  import { CellEditor } from './CellEditor.js';
8
8
  import { cellIdOf } from './cell-id.js';
9
- import { cellView } from './display.js';
9
+ import { columnNotation, cellView } from './display.js';
10
10
  import { EnumPicker } from './EnumPicker.js';
11
11
  import { ROW_HEIGHT } from './layout.js';
12
12
  /*
@@ -31,7 +31,7 @@ export const GridRowView = memo(function GridRowView({ row, index, selected, foc
31
31
  const cells = order.map((columnIndex, position) => {
32
32
  const column = columns[columnIndex];
33
33
  const cell = row.cells[columnIndex] ?? null;
34
- const view = cellView(cell, column.kind, notation);
34
+ const view = cellView(cell, column.kind, columnNotation(column, notation));
35
35
  const cellPosition = { row: index, column: columnIndex };
36
36
  const isFocused = focusedColumn === columnIndex;
37
37
  const ranged = picked.has(columnIndex) || (position >= rangeStart && position <= rangeEnd && rangeStart >= 0);
@@ -9,7 +9,7 @@ import { useNumberNotation } from '../client-context.js';
9
9
  import { CODE_TEXT } from '../code-text.js';
10
10
  import { ValueEditor } from '../value/ValueEditor.js';
11
11
  import { isNumericKind } from '../sql.js';
12
- import { cellView, copyTextOf, isPreview, numeralText } from './display.js';
12
+ import { columnNotation, cellView, copyTextOf, isPreview, numeralText } from './display.js';
13
13
  import { draftOf, parseDraft } from './edit-value.js';
14
14
  import { parseEnumType } from './enum-type.js';
15
15
  import { EnumPicker } from './EnumPicker.js';
@@ -147,7 +147,7 @@ export function RecordView({ columns, rows, index, onIndexChange, focusedColumn,
147
147
  }
148
148
  } })) : (_jsxs("span", { className: "relative flex min-w-0 items-center", children: [_jsx(Input, { size: "sm", spellCheck: false, "aria-label": column.name, ref: (node) => {
149
149
  fields.current[position] = node;
150
- }, value: active ? editing.draft : textOf(cell, column.kind, notation, !editableField), placeholder: placeholder, readOnly: !editableField || (preview && (!active || editing.loading)), className: clsx(CODE_TEXT, 'min-w-0 flex-1', row.state === 'deleted' && 'line-through'), onFocus: () => void beginEdit(position), onBlur: commitEdit, onChange: (event) => changeDraft(position, event.target.value), onKeyDown: (event) => handleKeyDown(event, position) }), active && editing.loading && _jsx(Spinner, { size: 12, label: t('grid.loadingValue'), className: "absolute right-2" })] }))] }), _jsxs(ContextMenu.Popup, { children: [_jsxs(ContextMenu.Item, { onClick: () => copyText(copyTextOf(cell)), children: [_jsx(Icon, { icon: Copy, size: 14 }), t('grid.copyValue'), _jsx(Kbd, { shortcut: EDIT_SHORTCUTS.copy })] }), editable && (_jsxs(_Fragment, { children: [_jsx(ContextMenu.Separator, {}), _jsxs(ContextMenu.Item, { disabled: !editableField || !nullable, onClick: () => commitValue(position, null), children: [_jsx(Icon, { icon: Ban, size: 14 }), t('grid.setNull')] }), _jsxs(ContextMenu.Item, { disabled: !editableField, onClick: () => commitValue(position, { kind: 'default' }), children: [_jsx(Icon, { icon: RotateCcw, size: 14 }), t('grid.setDefault')] })] }))] })] }, position));
150
+ }, value: active ? editing.draft : textOf(cell, column.kind, columnNotation(column, notation), !editableField), placeholder: placeholder, readOnly: !editableField || (preview && (!active || editing.loading)), className: clsx(CODE_TEXT, 'min-w-0 flex-1', row.state === 'deleted' && 'line-through'), onFocus: () => void beginEdit(position), onBlur: commitEdit, onChange: (event) => changeDraft(position, event.target.value), onKeyDown: (event) => handleKeyDown(event, position) }), active && editing.loading && _jsx(Spinner, { size: 12, label: t('grid.loadingValue'), className: "absolute right-2" })] }))] }), _jsxs(ContextMenu.Popup, { children: [_jsxs(ContextMenu.Item, { onClick: () => copyText(copyTextOf(cell)), children: [_jsx(Icon, { icon: Copy, size: 14 }), t('grid.copyValue'), _jsx(Kbd, { shortcut: EDIT_SHORTCUTS.copy })] }), editable && (_jsxs(_Fragment, { children: [_jsx(ContextMenu.Separator, {}), _jsxs(ContextMenu.Item, { disabled: !editableField || !nullable, onClick: () => commitValue(position, null), children: [_jsx(Icon, { icon: Ban, size: 14 }), t('grid.setNull')] }), _jsxs(ContextMenu.Item, { disabled: !editableField, onClick: () => commitValue(position, { kind: 'default' }), children: [_jsx(Icon, { icon: RotateCcw, size: 14 }), t('grid.setDefault')] })] }))] })] }, position));
151
151
  }) }))] }));
152
152
  }
153
153
  /* A field opened up to its whole value, fetched with `loadValue` when the row holds only a preview. */
@@ -1,4 +1,5 @@
1
1
  import type { Cell, EditValue, ValueKind } from '../protocol/index.ts';
2
+ import type { GridColumn } from './types.ts';
2
3
  export type Shown = Cell | {
3
4
  readonly kind: 'default';
4
5
  };
@@ -14,6 +15,7 @@ export declare const DISPLAY_LIMIT = 200;
14
15
  export declare const BINARY_PREVIEW_BYTES = 8;
15
16
  export declare const isPreview: (cell: Shown) => boolean;
16
17
  export declare const numeralText: (numeral: string | number, notation: NumberNotation) => string;
18
+ export declare function columnNotation(column: GridColumn, notation: NumberNotation): NumberNotation;
17
19
  export declare const cellView: (cell: Shown, kind: ValueKind, notation?: NumberNotation) => CellView;
18
20
  export declare const copyTextOf: (cell: Shown) => string;
19
21
  export declare const shownOfEdit: (value: EditValue) => Shown;
@@ -24,6 +24,17 @@ export const numeralText = (numeral, notation) => {
24
24
  const formatted = formatNumeral(numeral);
25
25
  return formatted === String(numeral).trim() ? String(numeral) : formatted;
26
26
  };
27
+ /* Identifiers are labels, even when the database stores their characters as a number. */
28
+ export function columnNotation(column, notation) {
29
+ const identifierName = (name) => /^(?:id|.+_id)$/i.test(name) || /(?:Id|ID)$/.test(name);
30
+ return column.primaryKey === true ||
31
+ column.foreignKey === true ||
32
+ column.autoIncrement === true ||
33
+ identifierName(column.name) ||
34
+ (column.source !== undefined && identifierName(column.source.column))
35
+ ? 'database'
36
+ : notation;
37
+ }
27
38
  const cut = (text) => (text.length > DISPLAY_LIMIT ? `${text.slice(0, DISPLAY_LIMIT)}${ELLIPSIS}` : text);
28
39
  export const cellView = (cell, kind, notation = 'database') => {
29
40
  const numeric = isNumericKind(kind);
@@ -0,0 +1,9 @@
1
+ import type { Value } from '../protocol/index.ts';
2
+ import { type Shown } from './display.ts';
3
+ export declare const WHOLE_COPY_LIMIT: number;
4
+ export interface WholeCells {
5
+ readonly rows: readonly (readonly Shown[])[];
6
+ readonly previews: number;
7
+ readonly tooLarge: boolean;
8
+ }
9
+ export declare const loadWholeCells: (rows: readonly (readonly Shown[])[], load: ((row: number, column: number) => Promise<Value | undefined>) | undefined) => Promise<WholeCells>;
@@ -0,0 +1,49 @@
1
+ import { isPreview, shownOfEdit } from './display.js';
2
+ /* Characters of whole values one copy fetches at most, a byte of a binary value counting as two: past it the copy would hold the clipboard and the session for too long. */
3
+ export const WHOLE_COPY_LIMIT = 16 * 1024 * 1024;
4
+ /* The helper runs the requests of a session one after another, so more at once would only wait in its queue. */
5
+ const PARALLEL_LOADS = 4;
6
+ const wholeSize = (cell) => {
7
+ if (cell === null || typeof cell !== 'object' || cell.kind === 'default') {
8
+ return 0;
9
+ }
10
+ return cell.kind === 'longText' ? cell.length : cell.length * 2;
11
+ };
12
+ /*
13
+ * A block of cells with every preview replaced by the whole value `load` fetches for it, by its row and
14
+ * column in the block. A cell `load` resolves `undefined` for, or rejects for, stays a preview.
15
+ */
16
+ export const loadWholeCells = async (rows, load) => {
17
+ const wanted = [];
18
+ let size = 0;
19
+ rows.forEach((cells, row) => cells.forEach((cell, column) => {
20
+ if (isPreview(cell)) {
21
+ wanted.push({ row, column });
22
+ size += wholeSize(cell);
23
+ }
24
+ }));
25
+ if (wanted.length === 0) {
26
+ return { rows, previews: 0, tooLarge: false };
27
+ }
28
+ if (load === undefined || size > WHOLE_COPY_LIMIT) {
29
+ return { rows, previews: wanted.length, tooLarge: load !== undefined };
30
+ }
31
+ const filled = rows.map((cells) => [...cells]);
32
+ let previews = 0;
33
+ let next = 0;
34
+ const work = async () => {
35
+ while (next < wanted.length) {
36
+ const { row, column } = wanted[next];
37
+ next += 1;
38
+ const value = await load(row, column).catch(() => undefined);
39
+ if (value === undefined) {
40
+ previews += 1;
41
+ }
42
+ else {
43
+ filled[row][column] = shownOfEdit(value);
44
+ }
45
+ }
46
+ };
47
+ await Promise.all(Array.from({ length: Math.min(PARALLEL_LOADS, wanted.length) }, work));
48
+ return { rows: filled, previews, tooLarge: false };
49
+ };