@adecore/database 0.23.1 → 0.24.1
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/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
|
|
package/dist/client/client.js
CHANGED
|
@@ -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
|
-
|
|
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)))
|
|
68
|
-
|
|
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 =
|
|
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
|
};
|
package/dist/client/types.d.ts
CHANGED
|
@@ -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:
|
|
294
|
+
page: before.length + 1,
|
|
297
295
|
loading: paging,
|
|
298
|
-
onPrevious: () => void loadPage(index, base,
|
|
299
|
-
onNext: () => void loadPage(index, base, offset +
|
|
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,
|
|
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 {
|
|
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
|
|
44
|
-
if (
|
|
43
|
+
const scope = runScopeOf(event, isApplePlatform());
|
|
44
|
+
if (scope !== null) {
|
|
45
45
|
event.preventDefault();
|
|
46
|
-
run(
|
|
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
|
-
|
|
3
|
-
export const
|
|
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
|
+
};
|
package/dist/grid/DataGrid.d.ts
CHANGED
|
@@ -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;
|
package/dist/grid/DataGrid.js
CHANGED
|
@@ -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
|
|
294
|
-
const
|
|
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
|
|
310
|
+
return;
|
|
297
311
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
|
305
|
-
const
|
|
306
|
-
|
|
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
|
-
|
|
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
|
-
|
|
321
|
-
|
|
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
|
}
|
package/dist/grid/GridRowView.js
CHANGED
|
@@ -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);
|
package/dist/grid/RecordView.js
CHANGED
|
@@ -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. */
|
package/dist/grid/display.d.ts
CHANGED
|
@@ -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;
|
package/dist/grid/display.js
CHANGED
|
@@ -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
|
+
};
|