@adecore/database 0.23.1 → 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
@@ -13,6 +13,7 @@ const SYSTEM_SCHEMAS = ['information_schema', 'mysql', 'performance_schema', 'sy
13
13
  const DEFAULT_ROWS_CELL_LIMIT = 1024;
14
14
  const DEFAULT_EXECUTE_LIMIT = 500;
15
15
  const DEFAULT_EXECUTE_CELL_LIMIT = 65536;
16
+ const DEFAULT_MAX_BYTES = 8388608;
16
17
  const toTables = (database) => new Map(Object.entries(database.schemas).map(([schema, tables]) => [
17
18
  schema,
18
19
  new Map(Object.entries(tables).map(([name, table]) => [
@@ -79,12 +80,20 @@ const toCell = (value, cellLimit) => {
79
80
  return value;
80
81
  };
81
82
  const toResultColumns = (table) => table.columns.map(({ name, type, kind }) => ({ name, type, kind, source: { schema: table.schema, table: table.name, column: name } }));
82
- const toRowsResult = (table, rows, offset, limit, cellLimit) => ({
83
- columns: toResultColumns(table),
84
- rows: rows.slice(offset, offset + limit).map((row) => row.map((value) => toCell(value, cellLimit))),
85
- hasMore: rows.length > offset + limit,
86
- elapsedMs: 0
87
- });
83
+ const toRowsResult = (table, rows, offset, limit, cellLimit, budget) => {
84
+ const page = [];
85
+ for (const row of rows.slice(offset, offset + limit)) {
86
+ const cells = row.map((value) => toCell(value, cellLimit));
87
+ // Characters rather than bytes: near enough for a fake.
88
+ const size = JSON.stringify(cells).length;
89
+ if (page.length > 0 && size > budget.left) {
90
+ return { columns: toResultColumns(table), rows: page, hasMore: true, elapsedMs: 0 };
91
+ }
92
+ budget.left = Math.max(0, budget.left - size);
93
+ page.push(cells);
94
+ }
95
+ return { columns: toResultColumns(table), rows: page, hasMore: rows.length > offset + limit, elapsedMs: 0 };
96
+ };
88
97
  /* Evaluates the default of a column for a row an insert or an update leaves it out of. */
89
98
  const defaultOf = (column, table) => {
90
99
  if (column.autoIncrement) {
@@ -172,10 +181,10 @@ const selectedTable = (session, sql) => {
172
181
  }
173
182
  return findTable(session, session.schema ?? session.schemas.keys().next().value ?? '', name);
174
183
  };
175
- const runStatement = (session, sql, limit, cellLimit) => {
184
+ const runStatement = (session, sql, limit, cellLimit, budget) => {
176
185
  try {
177
186
  const table = selectedTable(session, sql);
178
- return { kind: 'rows', sql, ...toRowsResult(table, table.rows, 0, limit, cellLimit) };
187
+ return { kind: 'rows', sql, ...toRowsResult(table, table.rows, 0, limit, cellLimit, budget) };
179
188
  }
180
189
  catch (error) {
181
190
  if (error instanceof FakeError) {
@@ -266,9 +275,9 @@ export const fakeDatabaseTransport = (options) => {
266
275
  ddl: table.ddl
267
276
  };
268
277
  },
269
- rows: ({ session, schema, table: name, offset, limit, cellLimit }) => {
278
+ rows: ({ session, schema, table: name, offset, limit, cellLimit, maxBytes }) => {
270
279
  const table = findTable(sessionOf(session), schema, name);
271
- return toRowsResult(table, table.rows, offset, limit, cellLimit ?? DEFAULT_ROWS_CELL_LIMIT);
280
+ return toRowsResult(table, table.rows, offset, limit, cellLimit ?? DEFAULT_ROWS_CELL_LIMIT, { left: maxBytes ?? DEFAULT_MAX_BYTES });
272
281
  },
273
282
  count: ({ session, schema, table: name }) => ({ count: findTable(sessionOf(session), schema, name).rows.length }),
274
283
  cell: ({ session, schema, table: name, key, column }) => {
@@ -290,15 +299,16 @@ export const fakeDatabaseTransport = (options) => {
290
299
  }
291
300
  return { affected: applyChanges(findTable(target, schema, name), changes) };
292
301
  },
293
- execute: ({ session, sql, schema, limit, cellLimit }) => {
302
+ execute: ({ session, sql, schema, limit, cellLimit, maxBytes }) => {
294
303
  const target = sessionOf(session);
295
304
  selectSchema(target, schema);
296
305
  const results = [];
306
+ const budget = { left: maxBytes ?? DEFAULT_MAX_BYTES };
297
307
  for (const statement of sql
298
308
  .split(';')
299
309
  .map((part) => part.trim())
300
310
  .filter((part) => part.length > 0)) {
301
- const result = runStatement(target, statement, limit ?? DEFAULT_EXECUTE_LIMIT, cellLimit ?? DEFAULT_EXECUTE_CELL_LIMIT);
311
+ const result = runStatement(target, statement, limit ?? DEFAULT_EXECUTE_LIMIT, cellLimit ?? DEFAULT_EXECUTE_CELL_LIMIT, budget);
302
312
  results.push(result);
303
313
  if (result.kind === 'error') {
304
314
  break;
@@ -306,11 +316,11 @@ export const fakeDatabaseTransport = (options) => {
306
316
  }
307
317
  return { results, inTransaction: target.inTransaction };
308
318
  },
309
- page: ({ session, sql, schema, offset, limit, cellLimit }) => {
319
+ page: ({ session, sql, schema, offset, limit, cellLimit, maxBytes }) => {
310
320
  const target = sessionOf(session);
311
321
  selectSchema(target, schema);
312
322
  const table = selectedTable(target, sql);
313
- return toRowsResult(table, table.rows, offset, limit, cellLimit ?? DEFAULT_EXECUTE_CELL_LIMIT);
323
+ return toRowsResult(table, table.rows, offset, limit, cellLimit ?? DEFAULT_EXECUTE_CELL_LIMIT, { left: maxBytes ?? DEFAULT_MAX_BYTES });
314
324
  },
315
325
  transaction: ({ session, action }) => {
316
326
  const target = sessionOf(session);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/database",
3
- "version": "0.23.1",
3
+ "version": "0.24.0",
4
4
  "description": "Browse, query and edit SQLite and MySQL databases: React views for the page, a host for the backend and a native helper that talks to the server.",
5
5
  "license": "FSL-1.1-MIT",
6
6
  "author": "Bas Milius",
@@ -56,25 +56,25 @@
56
56
  "helper:test": "cargo test --locked --manifest-path helper/Cargo.toml"
57
57
  },
58
58
  "dependencies": {
59
- "@base-ui-components/react": "^1.0.0-rc.0",
59
+ "@base-ui/react": "^1.9.0",
60
60
  "clsx": "^2.1.1",
61
61
  "lucide-react": "^1.47.0"
62
62
  },
63
63
  "optionalDependencies": {
64
- "@adecore/database-darwin-arm64": "0.23.1",
65
- "@adecore/database-linux-arm64": "0.23.1",
66
- "@adecore/database-linux-x64": "0.23.1",
67
- "@adecore/database-win32-x64": "0.23.1"
64
+ "@adecore/database-darwin-arm64": "0.24.0",
65
+ "@adecore/database-linux-arm64": "0.24.0",
66
+ "@adecore/database-linux-x64": "0.24.0",
67
+ "@adecore/database-win32-x64": "0.24.0"
68
68
  },
69
69
  "peerDependencies": {
70
- "@adecore/ui": "^0.23.1",
70
+ "@adecore/ui": "^0.24.0",
71
71
  "i18next": "^26.4.2",
72
72
  "react": "^19.3.0",
73
73
  "react-dom": "^19.3.0",
74
74
  "react-i18next": "^17.0.15"
75
75
  },
76
76
  "devDependencies": {
77
- "@adecore/ui": "workspace:*",
77
+ "@adecore/ui": "0.24.0",
78
78
  "@happy-dom/global-registrator": "^20.14.5",
79
79
  "@types/bun": "^1.4.2",
80
80
  "@types/node": "^24.13.6",
@@ -69,6 +69,8 @@ const raceAbort = <T>(promise: Promise<T>, signal: AbortSignal | undefined): Pro
69
69
  export const createDatabaseClient = (transport: DatabaseTransport, options: DatabaseClientOptions = {}): DatabaseClient => {
70
70
  const createId = options.createId ?? (() => crypto.randomUUID());
71
71
  const live = new Map<string, Map<string, ReturnType<typeof createSession>>>();
72
+ // Every session that opened on the helper and has not closed, also one a newer session replaced on its channel.
73
+ const held = new Set<ReturnType<typeof createSession>>();
72
74
  const schemaListeners = new Set<(change: SchemaChange) => void>();
73
75
 
74
76
  const channelsOf = (connectionId: string): Map<string, ReturnType<typeof createSession>> => {
@@ -93,7 +95,8 @@ export const createDatabaseClient = (transport: DatabaseTransport, options: Data
93
95
  }
94
96
  };
95
97
 
96
- const send = async <M extends DatabaseMethod>(method: M, params: DatabaseParams<M>, signal?: AbortSignal): Promise<DatabaseResult<M>> => {
98
+ /* With `settles`, an abort sends the cancel and waits for the answer, which can still carry what ran. */
99
+ const send = async <M extends DatabaseMethod>(method: M, params: DatabaseParams<M>, signal?: AbortSignal, settles = false): Promise<DatabaseResult<M>> => {
97
100
  if (signal?.aborted) {
98
101
  throw cancelledError();
99
102
  }
@@ -104,8 +107,15 @@ export const createDatabaseClient = (transport: DatabaseTransport, options: Data
104
107
  return new Promise<DatabaseResult<M>>((resolve, reject) => {
105
108
  const onAbort = (): void => {
106
109
  const cancel = { id: createId(), method: 'cancel', params: { request: id } } as DatabaseRequest;
107
- new Promise((done) => done(transport(cancel))).catch(() => {});
108
- reject(cancelledError());
110
+ const cancelling = new Promise((done) => done(transport(cancel)));
111
+
112
+ if (settles) {
113
+ // Without a cancel on its way, nothing tells the request to stop, so its answer may never come.
114
+ cancelling.catch(() => reject(cancelledError()));
115
+ } else {
116
+ cancelling.catch(() => {});
117
+ reject(cancelledError());
118
+ }
109
119
  };
110
120
 
111
121
  signal?.addEventListener('abort', onAbort, { once: true });
@@ -151,6 +161,7 @@ export const createDatabaseClient = (transport: DatabaseTransport, options: Data
151
161
  }
152
162
 
153
163
  track();
164
+ held.add(created);
154
165
  opening ??= send('open', { connection: connection.config })
155
166
  .then((result) => (opened = result))
156
167
  .finally(() => {
@@ -165,7 +176,8 @@ export const createDatabaseClient = (transport: DatabaseTransport, options: Data
165
176
  method: M,
166
177
  build: (session: string) => DatabaseParams<M>,
167
178
  requestOptions: RequestOptions | undefined,
168
- retry: boolean
179
+ retry: boolean,
180
+ settles = false
169
181
  ): Promise<DatabaseResult<M>> => {
170
182
  const signal = requestOptions?.signal;
171
183
 
@@ -176,7 +188,7 @@ export const createDatabaseClient = (transport: DatabaseTransport, options: Data
176
188
  const current = await raceAbort(open(), signal);
177
189
 
178
190
  try {
179
- return await send(method, build(current.session), signal);
191
+ return await send(method, build(current.session), signal, settles);
180
192
  } catch (error) {
181
193
  if (!isUnknownSession(error)) {
182
194
  throw error;
@@ -187,7 +199,7 @@ export const createDatabaseClient = (transport: DatabaseTransport, options: Data
187
199
  }
188
200
 
189
201
  if (retry) {
190
- return request(method, build, requestOptions, false);
202
+ return request(method, build, requestOptions, false, settles);
191
203
  }
192
204
 
193
205
  throw error;
@@ -205,6 +217,7 @@ export const createDatabaseClient = (transport: DatabaseTransport, options: Data
205
217
  }
206
218
  }
207
219
 
220
+ held.delete(created);
208
221
  const current = opened ?? (opening === null ? null : await opening.catch(() => null));
209
222
  opened = null;
210
223
 
@@ -244,7 +257,7 @@ export const createDatabaseClient = (transport: DatabaseTransport, options: Data
244
257
  },
245
258
  async execute(sql, executeOptions?: ExecuteOptions) {
246
259
  const { signal: _signal, ...rest } = executeOptions ?? {};
247
- const result = await request('execute', (id) => ({ session: id, sql, ...rest }), executeOptions, false);
260
+ const result = await request('execute', (id) => ({ session: id, sql, ...rest }), executeOptions, false, true);
248
261
 
249
262
  if (result.results.some((item) => item.kind !== 'error' && changesSchema(item.sql))) {
250
263
  notifySchemaChange({ connectionId: connection.id, ...(rest.schema === undefined ? {} : { schema: rest.schema }) });
@@ -310,14 +323,22 @@ export const createDatabaseClient = (transport: DatabaseTransport, options: Data
310
323
  return created.session;
311
324
  },
312
325
  async disconnect(connectionId) {
313
- const existing = [...(live.get(connectionId)?.values() ?? [])];
326
+ const existing = new Set(live.get(connectionId)?.values());
327
+ for (const entry of held) {
328
+ if (entry.session.connection.id === connectionId) {
329
+ existing.add(entry);
330
+ }
331
+ }
314
332
  live.delete(connectionId);
315
- await Promise.all(existing.map((entry) => entry.close()));
333
+ await Promise.all([...existing].map((entry) => entry.close()));
316
334
  },
317
335
  async dispose() {
318
- const all = [...live.values()].flatMap((channels) => [...channels.values()]);
336
+ const all = new Set([...live.values()].flatMap((channels) => [...channels.values()]));
337
+ for (const entry of held) {
338
+ all.add(entry);
339
+ }
319
340
  live.clear();
320
- await Promise.all(all.map((entry) => entry.close()));
341
+ await Promise.all([...all].map((entry) => entry.close()));
321
342
  }
322
343
  };
323
344
  };
@@ -46,18 +46,20 @@ export interface RowsQuery {
46
46
  readonly offset: number;
47
47
  readonly limit: number;
48
48
  readonly cellLimit?: number;
49
+ readonly maxBytes?: number;
49
50
  }
50
51
 
51
52
  export interface ExecuteOptions extends RequestOptions {
52
53
  readonly schema?: string;
53
54
  readonly limit?: number;
54
55
  readonly cellLimit?: number;
56
+ readonly maxBytes?: number;
55
57
  }
56
58
 
57
59
  /*
58
60
  * One open connection. It opens on its first request, and opens again when the host lost it (a helper
59
- * that exited); a read is then sent once more, a write or an `execute` fails with `unknown-session`,
60
- * since nobody can tell whether it ran.
61
+ * that exited, or a connection the server closed); a read is then sent once more, a write or an
62
+ * `execute` fails with `unknown-session`, since nobody can tell whether it ran.
61
63
  */
62
64
  export interface DatabaseSession {
63
65
  readonly connection: Connection;
@@ -69,6 +71,11 @@ export interface DatabaseSession {
69
71
  count(schema: string, table: string, where?: string, options?: RequestOptions): Promise<number>;
70
72
  cell(schema: string, table: string, key: RowKey, column: string, options?: RequestOptions): Promise<Value>;
71
73
  apply(schema: string, table: string, changes: readonly RowChange[], options?: RequestOptions): Promise<number>;
74
+ /*
75
+ * Aborting an `execute` the helper began waits for its answer: the results of what ran, ending with an
76
+ * `error` of code `cancelled`, and whether a transaction is open. It rejects with `cancelled` when
77
+ * nothing ran, or when the cancel could not be sent.
78
+ */
72
79
  execute(sql: string, options?: ExecuteOptions): Promise<ExecuteResult>;
73
80
  page(sql: string, query: PageQuery, options?: RequestOptions): Promise<RowsResult>;
74
81
  /* Resolves whether a transaction is open after the action. */
@@ -88,6 +95,7 @@ export interface PageQuery {
88
95
  readonly offset: number;
89
96
  readonly limit: number;
90
97
  readonly cellLimit?: number;
98
+ readonly maxBytes?: number;
91
99
  }
92
100
 
93
101
  export interface ExportRequest {
@@ -58,6 +58,8 @@ type Run =
58
58
  interface PageState {
59
59
  readonly offset: number;
60
60
  readonly rows: RowsResult;
61
+ /* Where each page before this one started: a page the byte limit cut short holds fewer rows than `PAGE_SIZE`. */
62
+ readonly before: readonly number[];
61
63
  }
62
64
 
63
65
  /* Rows in the first page of a result, and in every page after it. */
@@ -282,11 +284,8 @@ export function QueryConsole({
282
284
  if (mode === 'manual' && !inTransaction) {
283
285
  setInTransaction(await session.transaction('begin', { signal: controller.signal }));
284
286
  }
287
+ // A cancel that stopped the run midway still answers what ran and whether a transaction stayed open.
285
288
  const executed = await session.execute(target, { schema: activeSchema, limit: PAGE_SIZE, signal: controller.signal });
286
- if (controller.signal.aborted) {
287
- setRun({ status: 'cancelled' });
288
- return;
289
- }
290
289
  setInTransaction(executed.inTransaction);
291
290
  setTab(0);
292
291
  setPages({});
@@ -348,7 +347,7 @@ export function QueryConsole({
348
347
  }
349
348
  };
350
349
 
351
- const loadPage = async (index: number, result: StatementResult, offset: number): Promise<void> => {
350
+ const loadPage = async (index: number, result: StatementResult, offset: number, before: readonly number[]): Promise<void> => {
352
351
  if (session === null) {
353
352
  return;
354
353
  }
@@ -358,7 +357,7 @@ export function QueryConsole({
358
357
  try {
359
358
  const rows = await session.page(result.sql, { schema: activeSchema, offset, limit: PAGE_SIZE });
360
359
  if (generation.current === mine) {
361
- setPages((current) => ({ ...current, [index]: { offset, rows } }));
360
+ setPages((current) => ({ ...current, [index]: { offset, rows, before } }));
362
361
  }
363
362
  } catch (error) {
364
363
  notify({ tone: 'error', title: t('console.pageFailed'), description: messageOf(error) });
@@ -391,13 +390,14 @@ export function QueryConsole({
391
390
  const page = pages[index];
392
391
  const shown: StatementResult | undefined = base?.kind === 'rows' && page !== undefined ? { kind: 'rows', sql: base.sql, ...page.rows } : base;
393
392
  const offset = page?.offset ?? 0;
393
+ const before = page?.before ?? [];
394
394
  const pager: ResultPager | undefined =
395
395
  base?.kind === 'rows' && shown?.kind === 'rows' && engine !== undefined && isPageable(base.sql, engine) && (shown.hasMore || offset > 0)
396
396
  ? {
397
- page: offset / PAGE_SIZE + 1,
397
+ page: before.length + 1,
398
398
  loading: paging,
399
- onPrevious: () => void loadPage(index, base, Math.max(0, offset - PAGE_SIZE)),
400
- onNext: () => void loadPage(index, base, offset + PAGE_SIZE)
399
+ onPrevious: () => void loadPage(index, base, before.at(-1) ?? 0, before.slice(0, -1)),
400
+ onNext: () => void loadPage(index, base, offset + shown.rows.length, [...before, offset])
401
401
  }
402
402
  : undefined;
403
403
  const exportBase = files !== undefined && base?.kind === 'rows' ? base : undefined;
@@ -516,6 +516,7 @@ export function QueryConsole({
516
516
  valuePanelOpen={panelOpen}
517
517
  onValuePanelOpenChange={setPanelOpen}
518
518
  keys={keys}
519
+ onNotice={notify}
519
520
  onFollowReference={
520
521
  act === undefined || connection === undefined
521
522
  ? undefined
@@ -1,10 +1,10 @@
1
1
  import { useEffect, useImperativeHandle, useRef, type KeyboardEvent } from 'react';
2
2
  import clsx from 'clsx';
3
- import { isApplePlatform, matchesShortcut, TextArea } from '@adecore/ui';
3
+ import { isApplePlatform, TextArea } from '@adecore/ui';
4
4
  import { CODE_TEXT } from '../code-text.ts';
5
5
  import { applyEdit, indentEdit, newlineEdit, outdentEdit, type TextEdit } from './editing.ts';
6
6
  import type { QueryConsoleEditorProps } from './editor-slot.ts';
7
- import { RUN_ALL_SHORTCUT, RUN_SHORTCUT } from './shortcuts.ts';
7
+ import { runScopeOf } from './shortcuts.ts';
8
8
 
9
9
  /*
10
10
  * Where the SQL is typed when the app brings no editor of its own: a plain text area that fills the
@@ -46,15 +46,10 @@ export function SqlEditor({ ref, value, onValueChange, run, label, placeholder,
46
46
  };
47
47
 
48
48
  const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>): void => {
49
- const apple = isApplePlatform();
50
- if (matchesShortcut(RUN_ALL_SHORTCUT, event, apple)) {
49
+ const scope = runScopeOf(event, isApplePlatform());
50
+ if (scope !== null) {
51
51
  event.preventDefault();
52
- run('all');
53
- return;
54
- }
55
- if (matchesShortcut(RUN_SHORTCUT, event, apple)) {
56
- event.preventDefault();
57
- run('selection-or-statement');
52
+ run(scope);
58
53
  return;
59
54
  }
60
55
  if (event.nativeEvent.isComposing || event.metaKey || event.ctrlKey || event.altKey) {
@@ -3,6 +3,7 @@ import { ArrowUpRight, ChevronLeft, ChevronRight, CircleAlert, Download, PanelRi
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import { Banner, ButtonGroup, ContextMenu, Icon, IconButton, Menu, Spinner } from '@adecore/ui';
5
5
  import { formatDecimal, formatNumber } from '@adecore/ui/format';
6
+ import type { DatabaseNotice } from '../actions.ts';
6
7
  import { DataGrid } from '../grid/DataGrid.tsx';
7
8
  import { previewValueOf } from '../grid/focused-value.ts';
8
9
  import type { FocusedCell, GridMenuContext, GridRow } from '../grid/types.ts';
@@ -45,6 +46,8 @@ export interface StatementResultViewProps {
45
46
  keys?: ResultKeys | null;
46
47
  /* Offers Go to referenced row on a cell of a foreign key, as the table view does. */
47
48
  onFollowReference?(reference: Reference): void;
49
+ /* A copy from the grid that kept some values cut off. */
50
+ onNotice?(notice: DatabaseNotice): void;
48
51
  }
49
52
 
50
53
  /* One statement's outcome: a result set in a read-only grid, a count of affected rows, or the error the server gave. */
@@ -58,7 +61,8 @@ export function StatementResultView({
58
61
  valuePanelOpen = false,
59
62
  onValuePanelOpenChange,
60
63
  keys = null,
61
- onFollowReference
64
+ onFollowReference,
65
+ onNotice
62
66
  }: StatementResultViewProps) {
63
67
  const { t } = useTranslation('database');
64
68
  const [focus, setFocus] = useState<FocusedCell | null>(null);
@@ -69,6 +73,15 @@ export function StatementResultView({
69
73
  const sqlTarget = useMemo((): SqlTarget | undefined => (engine === undefined ? undefined : { engine, table: RESULT_TABLE }), [engine]);
70
74
  const elapsed = t('console.elapsed', { value: formatDecimal(result.elapsedMs) });
71
75
 
76
+ if (result.kind === 'error' && result.error.code === 'cancelled') {
77
+ return (
78
+ <div className="flex min-h-0 flex-1 flex-col gap-3 p-3">
79
+ <Banner icon={CircleAlert} tone="neutral" className="min-w-0" message={t('console.cancelled')} />
80
+ <p className="px-1 text-xs text-text-muted tabular-nums">{elapsed}</p>
81
+ </div>
82
+ );
83
+ }
84
+
72
85
  if (result.kind === 'error') {
73
86
  return (
74
87
  <div className="flex min-h-0 flex-1 flex-col gap-3 p-3">
@@ -149,6 +162,7 @@ export function StatementResultView({
149
162
  onFollow={follow}
150
163
  canFollow={(cell) => referenceAt(cell) !== null}
151
164
  menu={referenceMenu}
165
+ onNotice={onNotice}
152
166
  />
153
167
  </ValueDock>
154
168
  <footer className="flex h-10 shrink-0 items-center gap-4 border-t border-border px-3 text-xs text-text-muted">
@@ -1,4 +1,17 @@
1
- import { shortcut } from '@adecore/ui';
1
+ import { matchesShortcut, shortcut, type KeyLike } from '@adecore/ui';
2
+ import type { QueryConsoleRunScope } from './editor-slot.ts';
2
3
 
3
- export const RUN_SHORTCUT = shortcut('Mod+Enter');
4
- export const RUN_ALL_SHORTCUT = shortcut('Mod+Shift+Enter');
4
+ /* The keys the toolbar's tooltips name, which an editor binds to its two runs. */
5
+ export const RUN_SHORTCUT = /* @__PURE__ */ shortcut('Mod+Enter');
6
+ export const RUN_ALL_SHORTCUT = /* @__PURE__ */ shortcut('Mod+Shift+Enter');
7
+
8
+ /* The run a key event asks for, or null when it is neither run key. `apple` is the one `matchesShortcut` takes. */
9
+ export const runScopeOf = (event: KeyLike, apple: boolean): QueryConsoleRunScope | null => {
10
+ if (matchesShortcut(RUN_ALL_SHORTCUT, event, apple)) {
11
+ return 'all';
12
+ }
13
+ if (matchesShortcut(RUN_SHORTCUT, event, apple)) {
14
+ return 'selection-or-statement';
15
+ }
16
+ return null;
17
+ };
@@ -15,6 +15,8 @@ import clsx from 'clsx';
15
15
  import { Ban, Copy, RotateCcw } from 'lucide-react';
16
16
  import { useTranslation } from 'react-i18next';
17
17
  import { ContextMenu, copyText, EDIT_SHORTCUTS, Icon, isApplePlatform, isModHeld, Kbd, useContentSize } from '@adecore/ui';
18
+ import { formatNumber } from '@adecore/ui/format';
19
+ import type { DatabaseNotice } from '../actions.ts';
18
20
  import type { EditValue, Value } from '../protocol/index.ts';
19
21
  import { assignRef } from '../assign-ref.ts';
20
22
  import type { SqlTarget } from '../sql.ts';
@@ -27,9 +29,9 @@ import type { EditMove } from './CellEditor.tsx';
27
29
  import { captureLayout, restoreLayout, sameLayout, type GridLayout } from './column-layout.ts';
28
30
  import { NO_COLUMN_SELECTION, selectColumn, shownSelection, type ColumnSelection } from './column-selection.ts';
29
31
  import { displayOrder, hideColumn, pinnedCount, showAllColumns, togglePin, type ColumnView } from './column-view.ts';
30
- import { COPY_FORMATS, formatCopy, type CopyFormat, type CopyInput } from './copy-formats.ts';
32
+ import { COPY_FORMATS, formatCopy, type CopyFormat } from './copy-formats.ts';
31
33
  import { CopyAsMenu } from './CopyAsMenu.tsx';
32
- import { cellView, copyTextOf, isPreview, type CellView, type NumberNotation } from './display.ts';
34
+ import { columnNotation, cellView, copyTextOf, isPreview, type CellView, type NumberNotation, type Shown } from './display.ts';
33
35
  import { draftOf, parseDraft } from './edit-value.ts';
34
36
  import { parseEnumType } from './enum-type.ts';
35
37
  import { GridHeaderCell, type HeaderActions } from './GridHeaderCell.tsx';
@@ -53,6 +55,7 @@ import { moveFocus, type CellPosition, type NavigationKey } from './navigation.t
53
55
  import { selectRow } from './row-selection.ts';
54
56
  import { sortOnly, sortStateOf, type GridSort } from './sort.ts';
55
57
  import type { ColumnRequest, FocusedCell, FocusRequest, GridColumn, GridMenuContext, GridRow } from './types.ts';
58
+ import { loadWholeCells } from './whole-cells.ts';
56
59
 
57
60
  export interface DataGridProps {
58
61
  columns: readonly GridColumn[];
@@ -66,8 +69,10 @@ export interface DataGridProps {
66
69
  onSelectedKeysChange?(keys: ReadonlySet<string>): void;
67
70
  /* A cell was edited, or set to NULL or DEFAULT from its menu. */
68
71
  onCommit?(rowKey: string, column: number, value: EditValue): void;
69
- /* The whole value of a cell that is only a preview, which editing needs. Resolves `undefined` when it cannot be had, which cancels the edit. */
72
+ /* The whole value of a cell that is only a preview, which editing and copying need. Resolves `undefined` when it cannot be had, which cancels the edit. */
70
73
  loadValue?(rowKey: string, column: number): Promise<Value | undefined>;
74
+ /* A copy that kept some values cut off, which the grid has no place of its own to say. */
75
+ onNotice?(notice: DatabaseNotice): void;
71
76
  /* The sorted columns by name, in order. Only shown unless `onSortsChange` is given, which is what makes the headers sort. */
72
77
  sorts?: readonly GridSort[];
73
78
  onSortsChange?(sorts: readonly GridSort[]): void;
@@ -130,7 +135,7 @@ const estimateWidths = (columns: readonly GridColumn[], rows: readonly GridRow[]
130
135
  columns.map((column, index) =>
131
136
  estimateColumnWidth(
132
137
  column.name,
133
- rows.slice(0, 50).map((row) => textOf(cellView(row.cells[index] ?? null, column.kind, notation))),
138
+ rows.slice(0, 50).map((row) => textOf(cellView(row.cells[index] ?? null, column.kind, columnNotation(column, notation)))),
134
139
  column.primaryKey === true
135
140
  )
136
141
  );
@@ -150,6 +155,7 @@ export function DataGrid({
150
155
  onSelectedKeysChange,
151
156
  onCommit,
152
157
  loadValue,
158
+ onNotice,
153
159
  sorts,
154
160
  onSortsChange,
155
161
  onFocusedCellChange,
@@ -454,38 +460,55 @@ export function DataGrid({
454
460
  }
455
461
  };
456
462
 
457
- /* The cells of the range or of the picked columns, as the columns and rows a copy writes. */
458
- const blockOf = (): CopyInput | null => {
463
+ /* The cells from row `top` to `bottom` in the given columns, with the whole value of every preview `loadValue` can fetch. */
464
+ const wholeCellsOf = async (top: number, bottom: number, indexes: readonly number[]): Promise<readonly (readonly Shown[])[]> => {
465
+ const shown = rows.slice(top, bottom + 1);
466
+ const whole = await loadWholeCells(
467
+ shown.map((row) => indexes.map((index) => row.cells[index] ?? null)),
468
+ loadValue === undefined ? undefined : (row, column) => loadValue(shown[row]!.key, indexes[column]!)
469
+ );
470
+ if (whole.previews > 0) {
471
+ onNotice?.({
472
+ tone: 'error',
473
+ title: t('grid.copyPreviews.title', { count: whole.previews, formatted: formatNumber(whole.previews) }),
474
+ description: t(whole.tooLarge ? 'grid.copyPreviews.tooLarge' : 'grid.copyPreviews.unavailable')
475
+ });
476
+ }
477
+ return whole.rows;
478
+ };
479
+
480
+ const copyAs = async (format: CopyFormat): Promise<void> => {
459
481
  if (block === null) {
460
- return null;
482
+ return;
461
483
  }
462
- return {
463
- columns: block.columns.map((index) => ({ name: columns[index]!.name, kind: columns[index]!.kind })),
464
- rows: rows.slice(block.top, block.bottom + 1).map((row) => block.columns.map((index) => row.cells[index] ?? null)),
465
- target: sqlTarget
466
- };
484
+ const picked = block.columns;
485
+ const cells = await wholeCellsOf(block.top, block.bottom, picked);
486
+ await copyText(
487
+ formatCopy(format, {
488
+ columns: picked.map((index) => ({ name: columns[index]!.name, kind: columns[index]!.kind })),
489
+ rows: cells,
490
+ target: sqlTarget
491
+ })
492
+ );
467
493
  };
468
494
 
469
- const copyAs = (format: CopyFormat): void => {
470
- const block = blockOf();
471
- if (block !== null) {
472
- copyText(formatCopy(format, block));
473
- }
495
+ const copyOne = async (row: number, column: number): Promise<void> => {
496
+ const [cells] = await wholeCellsOf(row, row, [column]);
497
+ await copyText(copyTextOf(cells?.[0] ?? null));
474
498
  };
475
499
 
476
500
  /* One cell copies as it reads; a block copies as TSV, which a spreadsheet pastes into cells. */
477
501
  const copyRange = (): void => {
478
502
  if (block !== null && block.top === block.bottom && block.columns.length === 1) {
479
- copyText(copyTextOf(rows[block.top]?.cells[block.columns[0]!] ?? null));
503
+ void copyOne(block.top, block.columns[0]!);
480
504
  } else {
481
- copyAs('tsv');
505
+ void copyAs('tsv');
482
506
  }
483
507
  };
484
508
 
485
509
  const copyCell = (): void => {
486
- const row = menuTarget === null ? undefined : rows[menuTarget.row];
487
- if (row !== undefined && menuTarget?.column != null) {
488
- copyText(copyTextOf(row.cells[menuTarget.column] ?? null));
510
+ if (menuTarget !== null && menuTarget.column !== null && rows[menuTarget.row] !== undefined) {
511
+ void copyOne(menuTarget.row, menuTarget.column);
489
512
  }
490
513
  };
491
514
 
@@ -610,7 +633,7 @@ export function DataGrid({
610
633
  index,
611
634
  fitColumnWidth(
612
635
  column.name,
613
- rows.map((row) => textOf(cellView(row.cells[index] ?? null, column.kind, notation))),
636
+ rows.map((row) => textOf(cellView(row.cells[index] ?? null, column.kind, columnNotation(column, notation)))),
614
637
  column.primaryKey === true
615
638
  )
616
639
  );
@@ -773,7 +796,7 @@ export function DataGrid({
773
796
  <CopyAsMenu
774
797
  disabled={block === null}
775
798
  formats={sqlTarget === undefined ? COPY_FORMATS.filter((format) => format !== 'sql') : COPY_FORMATS}
776
- onCopy={copyAs}
799
+ onCopy={(format) => void copyAs(format)}
777
800
  />
778
801
  {menuContext !== null && menuCell !== null && queryMenu !== undefined && (
779
802
  <>
@@ -5,7 +5,7 @@ import { formatNumber } from '@adecore/ui/format';
5
5
  import { CODE_TEXT } from '../code-text.ts';
6
6
  import { CellEditor, type EditMove } from './CellEditor.tsx';
7
7
  import { cellIdOf } from './cell-id.ts';
8
- import { cellView, type CellView, type NumberNotation } from './display.ts';
8
+ import { columnNotation, cellView, type CellView, type NumberNotation } from './display.ts';
9
9
  import type { EnumType } from './enum-type.ts';
10
10
  import { EnumPicker } from './EnumPicker.tsx';
11
11
  import { ROW_HEIGHT } from './layout.ts';
@@ -88,7 +88,7 @@ export const GridRowView = memo(function GridRowView({ row, index, selected, foc
88
88
  const cells = order.map((columnIndex, position) => {
89
89
  const column = columns[columnIndex]!;
90
90
  const cell = row.cells[columnIndex] ?? null;
91
- const view = cellView(cell, column.kind, notation);
91
+ const view = cellView(cell, column.kind, columnNotation(column, notation));
92
92
  const cellPosition = { row: index, column: columnIndex };
93
93
  const isFocused = focusedColumn === columnIndex;
94
94
  const ranged = picked.has(columnIndex) || (position >= rangeStart && position <= rangeEnd && rangeStart >= 0);
@@ -9,7 +9,7 @@ import { CODE_TEXT } from '../code-text.ts';
9
9
  import type { EditValue, Value } from '../protocol/index.ts';
10
10
  import { ValueEditor } from '../value/ValueEditor.tsx';
11
11
  import { isNumericKind } from '../sql.ts';
12
- import { cellView, copyTextOf, isPreview, numeralText, type NumberNotation } from './display.ts';
12
+ import { columnNotation, cellView, copyTextOf, isPreview, numeralText, type NumberNotation } from './display.ts';
13
13
  import { draftOf, parseDraft } from './edit-value.ts';
14
14
  import { parseEnumType } from './enum-type.ts';
15
15
  import { EnumPicker } from './EnumPicker.tsx';
@@ -313,7 +313,7 @@ export function RecordView({
313
313
  ref={(node) => {
314
314
  fields.current[position] = node;
315
315
  }}
316
- value={active ? editing.draft : textOf(cell, column.kind, notation, !editableField)}
316
+ value={active ? editing.draft : textOf(cell, column.kind, columnNotation(column, notation), !editableField)}
317
317
  placeholder={placeholder}
318
318
  readOnly={!editableField || (preview && (!active || editing.loading))}
319
319
  className={clsx(CODE_TEXT, 'min-w-0 flex-1', row.state === 'deleted' && 'line-through')}