@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.
- package/README.md +2 -2
- package/dist/client/client.js +31 -11
- package/dist/client/types.d.ts +3 -0
- package/dist/console/QueryConsole.js +8 -10
- package/dist/console/SqlEditor.js +5 -10
- package/dist/console/StatementResultView.d.ts +3 -1
- package/dist/console/StatementResultView.js +5 -2
- package/dist/console/shortcuts.d.ts +3 -0
- package/dist/console/shortcuts.js +14 -3
- package/dist/grid/DataGrid.d.ts +3 -1
- package/dist/grid/DataGrid.js +35 -22
- package/dist/grid/GridRowView.js +2 -2
- package/dist/grid/RecordView.js +2 -2
- package/dist/grid/display.d.ts +2 -0
- package/dist/grid/display.js +11 -0
- package/dist/grid/whole-cells.d.ts +9 -0
- package/dist/grid/whole-cells.js +49 -0
- package/dist/host/helper-path.d.ts +2 -1
- package/dist/host/helper-path.js +26 -5
- package/dist/host/host.js +5 -0
- package/dist/host/validate.js +8 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/locales/en.d.ts +601 -0
- package/dist/locales/en.js +600 -0
- package/dist/locales/nl.d.ts +601 -0
- package/dist/locales/nl.js +600 -0
- package/dist/locales.js +3 -2
- package/dist/protocol/messages.d.ts +4 -1
- package/dist/protocol/messages.js +1 -1
- package/dist/table/TableView.js +1 -1
- package/dist/testing/fake.js +24 -14
- package/package.json +8 -8
- package/src/client/client.ts +32 -11
- package/src/client/types.ts +10 -2
- package/src/console/QueryConsole.tsx +10 -9
- package/src/console/SqlEditor.tsx +5 -10
- package/src/console/StatementResultView.tsx +15 -1
- package/src/console/shortcuts.ts +16 -3
- package/src/grid/DataGrid.tsx +47 -24
- package/src/grid/GridRowView.tsx +2 -2
- package/src/grid/RecordView.tsx +2 -2
- package/src/grid/display.ts +13 -0
- package/src/grid/whole-cells.ts +72 -0
- package/src/host/helper-path.ts +42 -4
- package/src/host/host.ts +6 -0
- package/src/host/validate.ts +9 -3
- package/src/index.ts +1 -0
- package/src/locales/en.ts +600 -0
- package/src/locales/nl.ts +600 -0
- package/src/locales.ts +3 -2
- package/src/protocol/messages.ts +21 -4
- package/src/table/TableView.tsx +2 -1
- package/src/testing/fake.ts +29 -14
- package/dist/locales/en.json +0 -594
- package/dist/locales/nl.json +0 -594
- package/src/locales/en.json +0 -594
- package/src/locales/nl.json +0 -594
package/dist/testing/fake.js
CHANGED
|
@@ -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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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.
|
|
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
|
|
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.
|
|
65
|
-
"@adecore/database-linux-arm64": "0.
|
|
66
|
-
"@adecore/database-linux-x64": "0.
|
|
67
|
-
"@adecore/database-win32-x64": "0.
|
|
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.
|
|
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": "
|
|
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",
|
package/src/client/client.ts
CHANGED
|
@@ -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
|
-
|
|
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)))
|
|
108
|
-
|
|
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 =
|
|
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
|
};
|
package/src/client/types.ts
CHANGED
|
@@ -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
|
|
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:
|
|
397
|
+
page: before.length + 1,
|
|
398
398
|
loading: paging,
|
|
399
|
-
onPrevious: () => void loadPage(index, base,
|
|
400
|
-
onNext: () => void loadPage(index, base, offset +
|
|
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,
|
|
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 {
|
|
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
|
|
50
|
-
if (
|
|
49
|
+
const scope = runScopeOf(event, isApplePlatform());
|
|
50
|
+
if (scope !== null) {
|
|
51
51
|
event.preventDefault();
|
|
52
|
-
run(
|
|
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">
|
package/src/console/shortcuts.ts
CHANGED
|
@@ -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
|
-
|
|
4
|
-
export const
|
|
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
|
+
};
|
package/src/grid/DataGrid.tsx
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
458
|
-
const
|
|
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
|
|
482
|
+
return;
|
|
461
483
|
}
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
|
470
|
-
const
|
|
471
|
-
|
|
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
|
-
|
|
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
|
-
|
|
487
|
-
|
|
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
|
<>
|
package/src/grid/GridRowView.tsx
CHANGED
|
@@ -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);
|
package/src/grid/RecordView.tsx
CHANGED
|
@@ -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')}
|