@adecore/database 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/README.md +1 -1
  2. package/dist/DatabaseProvider.d.ts +4 -3
  3. package/dist/DatabaseProvider.js +15 -3
  4. package/dist/KeyIcon.d.ts +4 -0
  5. package/dist/KeyIcon.js +14 -0
  6. package/dist/actions.d.ts +12 -0
  7. package/dist/client-context.d.ts +3 -1
  8. package/dist/client-context.js +2 -0
  9. package/dist/code-text.d.ts +1 -0
  10. package/dist/code-text.js +2 -0
  11. package/dist/column-keys.d.ts +5 -0
  12. package/dist/column-keys.js +2 -0
  13. package/dist/connections/ConnectionManager.d.ts +3 -2
  14. package/dist/connections/ConnectionManager.js +27 -16
  15. package/dist/connections/connection-config.js +2 -0
  16. package/dist/console/QueryConsole.d.ts +8 -3
  17. package/dist/console/QueryConsole.js +110 -50
  18. package/dist/console/SqlEditor.d.ts +2 -12
  19. package/dist/console/SqlEditor.js +29 -18
  20. package/dist/console/StatementResultView.d.ts +5 -1
  21. package/dist/console/StatementResultView.js +18 -5
  22. package/dist/console/TransactionControls.js +3 -6
  23. package/dist/console/editor-slot.d.ts +18 -0
  24. package/dist/console/editor-slot.js +1 -0
  25. package/dist/console/result-keys.d.ts +15 -0
  26. package/dist/console/result-keys.js +79 -0
  27. package/dist/designer/ColumnsEditor.js +2 -2
  28. package/dist/designer/DesignerView.d.ts +3 -2
  29. package/dist/designer/DesignerView.js +5 -4
  30. package/dist/designer/SqlPreview.js +2 -1
  31. package/dist/designer/TableDesigner.d.ts +3 -2
  32. package/dist/designer/TableDesigner.js +2 -2
  33. package/dist/explorer/DatabaseExplorer.d.ts +4 -1
  34. package/dist/explorer/DatabaseExplorer.js +79 -23
  35. package/dist/explorer/RowMenu.js +2 -2
  36. package/dist/explorer/expansion.js +2 -2
  37. package/dist/explorer/folders.d.ts +16 -0
  38. package/dist/explorer/folders.js +1 -0
  39. package/dist/explorer/tree.d.ts +55 -4
  40. package/dist/explorer/tree.js +137 -23
  41. package/dist/grid/CellEditor.js +3 -1
  42. package/dist/grid/DataGrid.d.ts +2 -2
  43. package/dist/grid/DataGrid.js +61 -59
  44. package/dist/grid/EnumPicker.js +2 -1
  45. package/dist/grid/GridHeaderCell.d.ts +1 -2
  46. package/dist/grid/GridHeaderCell.js +12 -5
  47. package/dist/grid/GridRowView.d.ts +49 -0
  48. package/dist/grid/GridRowView.js +51 -0
  49. package/dist/grid/RecordView.d.ts +4 -2
  50. package/dist/grid/RecordView.js +71 -22
  51. package/dist/grid/ValueDock.d.ts +3 -1
  52. package/dist/grid/ValueDock.js +9 -3
  53. package/dist/grid/cell-id.d.ts +1 -0
  54. package/dist/grid/cell-id.js +2 -0
  55. package/dist/grid/layout.d.ts +3 -2
  56. package/dist/grid/layout.js +15 -6
  57. package/dist/grid/types.d.ts +8 -1
  58. package/dist/grid/types.js +4 -3
  59. package/dist/index.d.ts +5 -1
  60. package/dist/index.js +2 -0
  61. package/dist/locales/en.json +32 -15
  62. package/dist/locales/nl.json +32 -15
  63. package/dist/protocol/index.d.ts +1 -1
  64. package/dist/protocol/messages.d.ts +1 -1
  65. package/dist/protocol/messages.js +1 -1
  66. package/dist/protocol/schema.d.ts +17 -0
  67. package/dist/structure/StructureView.d.ts +5 -3
  68. package/dist/structure/StructureView.js +8 -6
  69. package/dist/table/CommandField.js +2 -2
  70. package/dist/table/TableStatusBar.js +2 -2
  71. package/dist/table/TableToolbar.d.ts +3 -4
  72. package/dist/table/TableToolbar.js +4 -3
  73. package/dist/table/TableView.d.ts +4 -2
  74. package/dist/table/TableView.js +63 -91
  75. package/dist/table/grid-rows.js +4 -1
  76. package/dist/table/useTableTransfer.d.ts +3 -1
  77. package/dist/table/useTableTransfer.js +19 -5
  78. package/dist/testing/fake.d.ts +3 -1
  79. package/dist/testing/fake.js +7 -1
  80. package/dist/value/ValueEditor.d.ts +9 -0
  81. package/dist/value/ValueEditor.js +102 -0
  82. package/dist/value/ValuePanel.d.ts +1 -1
  83. package/dist/value/ValuePanel.js +3 -94
  84. package/dist/workbench/DatabaseWorkbench.js +4 -7
  85. package/package.json +6 -6
  86. package/src/DatabaseProvider.tsx +24 -5
  87. package/src/KeyIcon.tsx +15 -0
  88. package/src/actions.ts +28 -2
  89. package/src/client-context.ts +5 -1
  90. package/src/code-text.ts +2 -0
  91. package/src/column-keys.ts +8 -0
  92. package/src/connections/ConnectionManager.tsx +98 -68
  93. package/src/connections/connection-config.ts +2 -0
  94. package/src/console/QueryConsole.tsx +251 -150
  95. package/src/console/SqlEditor.tsx +33 -37
  96. package/src/console/StatementResultView.tsx +61 -27
  97. package/src/console/TransactionControls.tsx +8 -11
  98. package/src/console/editor-slot.ts +27 -0
  99. package/src/console/result-keys.ts +109 -0
  100. package/src/designer/ColumnsEditor.tsx +3 -3
  101. package/src/designer/DesignerView.tsx +14 -3
  102. package/src/designer/SqlPreview.tsx +2 -1
  103. package/src/designer/TableDesigner.tsx +5 -2
  104. package/src/explorer/DatabaseExplorer.tsx +170 -26
  105. package/src/explorer/RowMenu.tsx +2 -2
  106. package/src/explorer/expansion.ts +4 -4
  107. package/src/explorer/folders.ts +25 -0
  108. package/src/explorer/tree.ts +219 -28
  109. package/src/grid/CellEditor.tsx +3 -2
  110. package/src/grid/DataGrid.tsx +114 -176
  111. package/src/grid/EnumPicker.tsx +2 -1
  112. package/src/grid/GridHeaderCell.tsx +14 -9
  113. package/src/grid/GridRowView.tsx +184 -0
  114. package/src/grid/RecordView.tsx +188 -75
  115. package/src/grid/ValueDock.tsx +12 -3
  116. package/src/grid/cell-id.ts +2 -0
  117. package/src/grid/layout.ts +16 -6
  118. package/src/grid/types.ts +15 -3
  119. package/src/index.ts +5 -1
  120. package/src/locales/en.json +32 -15
  121. package/src/locales/nl.json +32 -15
  122. package/src/protocol/index.ts +13 -1
  123. package/src/protocol/messages.ts +1 -1
  124. package/src/protocol/schema.ts +28 -0
  125. package/src/structure/StructureView.tsx +16 -6
  126. package/src/table/CommandField.tsx +2 -2
  127. package/src/table/TableStatusBar.tsx +3 -3
  128. package/src/table/TableToolbar.tsx +107 -92
  129. package/src/table/TableView.tsx +95 -132
  130. package/src/table/grid-rows.ts +5 -3
  131. package/src/table/useTableTransfer.ts +21 -6
  132. package/src/testing/fake.ts +18 -1
  133. package/src/value/ValueEditor.tsx +213 -0
  134. package/src/value/ValuePanel.tsx +4 -206
  135. package/src/workbench/DatabaseWorkbench.tsx +9 -10
@@ -1,15 +1,16 @@
1
- import { useEffect, useId, useMemo, useRef, useState, type Ref } from 'react';
1
+ import { useEffect, useId, useMemo, useRef, useState, type ReactNode, type Ref } from 'react';
2
2
  import clsx from 'clsx';
3
- import { Check, CircleAlert, History, Play, Square } from 'lucide-react';
4
- import type { TFunction } from 'i18next';
3
+ import { Check, CircleAlert, History, Play, Square, X } from 'lucide-react';
5
4
  import { useTranslation } from 'react-i18next';
6
- import { Banner, Button, Icon, messageOf, PromptDialog, Spinner, Tabs, Tooltip } from '@adecore/ui';
5
+ import { Banner, Button, ColumnResizeHandle, Icon, IconButton, messageOf, PromptDialog, Spinner, Tabs, Tooltip, useColumnResize } from '@adecore/ui';
7
6
  import { formatNumber } from '@adecore/ui/format';
8
7
  import { DatabaseRequestError, type Connection } from '../client/types.ts';
9
- import { useDatabaseClient, useDatabaseFiles } from '../client-context.ts';
8
+ import type { DatabaseNotice } from '../actions.ts';
9
+ import { useDatabaseAction, useDatabaseClient, useDatabaseFiles, useDatabaseNotice, useDatabaseStorage } from '../client-context.ts';
10
10
  import type { FileFormat, RowsResult, SchemaInfo, StatementResult } from '../protocol/index.ts';
11
11
  import { splitStatements, statementAt } from '../sql-split.ts';
12
12
  import { DestructiveDialog } from './DestructiveDialog.tsx';
13
+ import type { QueryConsoleEditorHandle, QueryConsoleEditorProps, QueryConsoleRunScope } from './editor-slot.ts';
13
14
  import { findDestructive, type DestructiveStatement } from './destructive.ts';
14
15
  import type { HistoryEntry } from './history.ts';
15
16
  import { HistoryPanel } from './HistoryPanel.tsx';
@@ -17,16 +18,20 @@ import { statementLabel } from './labels.ts';
17
18
  import { isPageable, outcomeOf } from './outcome.ts';
18
19
  import { SchemaPicker } from './SchemaPicker.tsx';
19
20
  import { orderSchemas } from './schemas.ts';
21
+ import { useResultKeys } from './result-keys.ts';
20
22
  import { RUN_ALL_SHORTCUT, RUN_SHORTCUT } from './shortcuts.ts';
21
- import { SqlEditor, type RunScope } from './SqlEditor.tsx';
23
+ import { SqlEditor } from './SqlEditor.tsx';
22
24
  import { StatementResultView, type ResultPager } from './StatementResultView.tsx';
23
25
  import { TransactionControls, type TransactionMode } from './TransactionControls.tsx';
24
26
  import { useConsoleHistory } from './useConsoleHistory.ts';
25
27
 
26
28
  export interface QueryConsoleProps {
27
- connection: Connection;
28
- /* The schema the statements run in. */
29
+ /* Without one the console draws its editor and a bar with only `toolbarEnd`, where an app can offer to pick one; nothing runs. */
30
+ connection?: Connection;
31
+ /* The schema the statements run in. Without `onSchemaChange` the picker starts from it, and a new one resets the pick. */
29
32
  schema?: string;
33
+ /* Makes the schema the app's: the picker shows `schema` and asks for another through this. */
34
+ onSchemaChange?(schema: string): void;
30
35
  /* The SQL, when the app keeps it (a tab that survives a reload). Without it the console keeps the text itself, starting from `defaultValue`. */
31
36
  value?: string;
32
37
  defaultValue?: string;
@@ -35,6 +40,10 @@ export interface QueryConsoleProps {
35
40
  defaultHistoryOpen?: boolean;
36
41
  /* Puts the caret in the editor when the console mounts, for one a person just opened. */
37
42
  autoFocus?: boolean;
43
+ /* Draws the app's own editor in place of the console's text area. It owns its keys and runs through `run`. */
44
+ renderEditor?(editor: QueryConsoleEditorProps): ReactNode;
45
+ /* The app's own content in the group at the end of the bar, before the schema picker and History, such as where the console runs. */
46
+ toolbarEnd?: ReactNode;
38
47
  className?: string;
39
48
  ref?: Ref<HTMLDivElement>;
40
49
  }
@@ -46,70 +55,74 @@ type Run =
46
55
  | { readonly status: 'failed'; readonly message: string }
47
56
  | { readonly status: 'cancelled' };
48
57
 
49
- /* What a run is about to execute, and how the toolbar names it afterwards. */
50
- interface RunTarget {
51
- readonly sql: string;
52
- readonly ran: Ran;
53
- }
54
-
55
- type Ran = { readonly kind: 'statement'; readonly label: string } | { readonly kind: 'selection' } | { readonly kind: 'all'; readonly count: number };
56
-
57
58
  interface PageState {
58
59
  readonly offset: number;
59
60
  readonly rows: RowsResult;
60
61
  }
61
62
 
62
- interface Notice {
63
- readonly tone: 'error' | 'neutral';
64
- readonly message: string;
65
- }
66
-
67
63
  /* Rows in the first page of a result, and in every page after it. */
68
64
  const PAGE_SIZE = 500;
69
65
 
70
- const ranText = (ran: Ran, t: TFunction<'database'>): string => {
71
- switch (ran.kind) {
72
- case 'statement':
73
- return t('console.ranStatement', { statement: ran.label });
74
- case 'selection':
75
- return t('console.ranSelection');
76
- case 'all':
77
- return t('console.ranAll', { count: ran.count, formatted: formatNumber(ran.count) });
66
+ /* Where the height of the results is kept, in pixels, for every console. */
67
+ export const RESULTS_HEIGHT_KEY = 'database:console-results';
68
+
69
+ /* The results and the editor never shrink below these; the results start at half the console. */
70
+ const MIN_RESULTS_HEIGHT = 96;
71
+ const MIN_EDITOR_HEIGHT = 120;
72
+
73
+ const parseHeight = (raw: string | null | undefined): number | null => {
74
+ try {
75
+ const value: unknown = JSON.parse(raw ?? 'null');
76
+ return typeof value === 'number' && Number.isFinite(value) && value >= MIN_RESULTS_HEIGHT ? Math.round(value) : null;
77
+ } catch {
78
+ return null;
78
79
  }
79
80
  };
80
81
 
82
+ /* The app's editor as an element of its own, so it gets its props the way `SqlEditor` does and not during the console's render. */
83
+ function AppEditor({ render, ...editor }: QueryConsoleEditorProps & { render(editor: QueryConsoleEditorProps): ReactNode }) {
84
+ return render(editor);
85
+ }
86
+
81
87
  /* Type SQL, run it, and read what each statement did. Several statements give a tab each. */
82
88
  export function QueryConsole({
83
89
  connection,
84
90
  schema,
91
+ onSchemaChange,
85
92
  value,
86
93
  defaultValue = '',
87
94
  onValueChange,
88
95
  defaultHistoryOpen = false,
89
96
  autoFocus = false,
97
+ renderEditor,
98
+ toolbarEnd,
90
99
  className,
91
100
  ref
92
101
  }: QueryConsoleProps) {
93
102
  const { t } = useTranslation('database');
94
103
  const client = useDatabaseClient();
104
+ const act = useDatabaseAction();
105
+ const onNotice = useDatabaseNotice();
95
106
  const files = useDatabaseFiles();
107
+ const storage = useDatabaseStorage();
96
108
  // A channel of its own, so a transaction a person starts here stays out of the table views and the designer.
97
109
  const channel = `console:${useId()}`;
98
- const session = useMemo(() => client.session(connection, channel), [client, connection, channel]);
99
- const engine = connection.config.engine;
110
+ const session = useMemo(() => (connection === undefined ? null : client.session(connection, channel)), [client, connection, channel]);
111
+ const engine = connection?.config.engine;
100
112
  useEffect(
101
113
  () => () => {
102
- void session.close();
114
+ void session?.close();
103
115
  },
104
116
  [session]
105
117
  );
106
- const history = useConsoleHistory(connection.id);
118
+ // Nothing is recorded without a connection, so the key of none is never written.
119
+ const history = useConsoleHistory(connection?.id ?? '');
107
120
  const running = useRef<AbortController | null>(null);
108
121
  const generation = useRef(0);
109
- const editor = useRef<HTMLTextAreaElement>(null);
122
+ const editorHandle = useRef<QueryConsoleEditorHandle>(null);
123
+ const resultsPane = useRef<HTMLElement>(null);
110
124
  const [own, setOwn] = useState(defaultValue);
111
125
  const [run, setRun] = useState<Run>({ status: 'idle' });
112
- const [ran, setRan] = useState<Ran | null>(null);
113
126
  const [tab, setTab] = useState(0);
114
127
  const [pages, setPages] = useState<Readonly<Record<number, PageState>>>({});
115
128
  const [paging, setPaging] = useState(false);
@@ -119,16 +132,27 @@ export function QueryConsole({
119
132
  const [inTransaction, setInTransaction] = useState(false);
120
133
  const [settling, setSettling] = useState(false);
121
134
  const [endingManual, setEndingManual] = useState(false);
122
- const [confirming, setConfirming] = useState<{ readonly target: RunTarget; readonly statements: readonly DestructiveStatement[] } | null>(null);
135
+ const [confirming, setConfirming] = useState<{ readonly target: string; readonly statements: readonly DestructiveStatement[] } | null>(null);
123
136
  const [schemas, setSchemas] = useState<readonly SchemaInfo[]>([]);
124
137
  const [seededSchema, setSeededSchema] = useState(schema);
125
138
  const [pickedSchema, setPickedSchema] = useState<string | undefined>(undefined);
126
139
  const [exporting, setExporting] = useState(false);
127
- const [notice, setNotice] = useState<Notice | null>(null);
140
+ // What the console shows itself when the app takes no notices.
141
+ const [notice, setNotice] = useState<DatabaseNotice | null>(null);
142
+ /* Closed until the first run, so the editor has the whole height before anything ran. */
143
+ const [resultsOpen, setResultsOpen] = useState(false);
144
+ const [resultsHeight, setResultsHeight] = useState(() => parseHeight(storage?.get(RESULTS_HEIGHT_KEY)));
145
+ const { startResize } = useColumnResize(resultsPane, {
146
+ size: resultsHeight ?? MIN_RESULTS_HEIGHT,
147
+ min: MIN_RESULTS_HEIGHT,
148
+ from: 'bottom',
149
+ max: () => (resultsPane.current?.parentElement?.clientHeight ?? 0) - MIN_EDITOR_HEIGHT,
150
+ onSize: setResultsHeight
151
+ });
128
152
  const sql = value ?? own;
129
153
  const blank = sql.trim() === '';
130
154
  const busy = run.status === 'running' || settling;
131
- const activeSchema = pickedSchema ?? schema;
155
+ const activeSchema = onSchemaChange === undefined ? (pickedSchema ?? schema) : schema;
132
156
 
133
157
  if (seededSchema !== schema) {
134
158
  setSeededSchema(schema);
@@ -138,15 +162,13 @@ export function QueryConsole({
138
162
  useEffect(() => () => running.current?.abort(), []);
139
163
 
140
164
  useEffect(() => {
141
- if (autoFocus) {
142
- editor.current?.focus();
165
+ if (resultsHeight !== null) {
166
+ storage?.set(RESULTS_HEIGHT_KEY, JSON.stringify(resultsHeight));
143
167
  }
144
- // Only on mount: a console that later becomes active keeps the focus where the person put it.
145
- // eslint-disable-next-line react/exhaustive-deps
146
- }, []);
168
+ }, [storage, resultsHeight]);
147
169
 
148
170
  useEffect(() => {
149
- if (engine !== 'mysql') {
171
+ if (session === null || engine !== 'mysql') {
150
172
  return undefined;
151
173
  }
152
174
  const controller = new AbortController();
@@ -157,6 +179,14 @@ export function QueryConsole({
157
179
  return () => controller.abort();
158
180
  }, [engine, session]);
159
181
 
182
+ const notify = (next: DatabaseNotice): void => {
183
+ if (onNotice === undefined) {
184
+ setNotice(next);
185
+ } else {
186
+ onNotice(next);
187
+ }
188
+ };
189
+
160
190
  const changeSql = (next: string): void => {
161
191
  if (value === undefined) {
162
192
  setOwn(next);
@@ -164,30 +194,28 @@ export function QueryConsole({
164
194
  onValueChange?.(next);
165
195
  };
166
196
 
167
- const targetOf = (scope: RunScope): RunTarget | null => {
168
- if (scope === 'all') {
169
- const statements = splitStatements(sql, engine);
170
- const [only] = statements;
171
- if (only === undefined) {
172
- return null;
173
- }
174
- return { sql, ran: statements.length > 1 ? { kind: 'all', count: statements.length } : { kind: 'statement', label: statementLabel(only.text) } };
197
+ /* The SQL a run of the scope sends, or null when there is nothing to run. */
198
+ const targetOf = (scope: QueryConsoleRunScope): string | null => {
199
+ if (engine === undefined) {
200
+ return null;
201
+ }
202
+ const selection = editorHandle.current?.selection();
203
+ if (scope === 'all' || selection === undefined) {
204
+ return splitStatements(sql, engine).length === 0 ? null : sql;
175
205
  }
176
- const start = editor.current?.selectionStart ?? 0;
177
- const end = editor.current?.selectionEnd ?? 0;
206
+ const { start, end } = selection;
178
207
  const selected = sql.slice(start, end);
179
208
  if (splitStatements(selected, engine).length > 0) {
180
- return { sql: selected, ran: { kind: 'selection' } };
209
+ return selected;
181
210
  }
182
- const statement = statementAt(sql, start, engine);
183
- return statement === null ? null : { sql: statement.text, ran: { kind: 'statement', label: statementLabel(statement.text) } };
211
+ return statementAt(sql, start, engine)?.text ?? null;
184
212
  };
185
213
 
186
- const requestRun = (target: RunTarget | null): void => {
187
- if (target === null || busy) {
214
+ const requestRun = (target: string | null): void => {
215
+ if (target === null || busy || engine === undefined) {
188
216
  return;
189
217
  }
190
- const statements = findDestructive(target.sql, engine);
218
+ const statements = findDestructive(target, engine);
191
219
  if (statements.length > 0) {
192
220
  setConfirming({ target, statements });
193
221
  return;
@@ -195,20 +223,23 @@ export function QueryConsole({
195
223
  void execute(target);
196
224
  };
197
225
 
198
- const execute = async (target: RunTarget): Promise<void> => {
226
+ const execute = async (target: string): Promise<void> => {
227
+ if (session === null || connection === undefined) {
228
+ return;
229
+ }
199
230
  const controller = new AbortController();
200
231
  const mine = ++generation.current;
201
232
  running.current = controller;
233
+ setResultsOpen(true);
202
234
  setRun({ status: 'running' });
203
- setRan(target.ran);
204
235
  setNotice(null);
205
236
  const record = (outcome: Pick<HistoryEntry, 'ok' | 'rows'>): void =>
206
- history.record({ sql: target.sql, at: Date.now(), connection: connection.name, ...outcome });
237
+ history.record({ sql: target, at: Date.now(), connection: connection.name, ...outcome });
207
238
  try {
208
239
  if (mode === 'manual' && !inTransaction) {
209
240
  setInTransaction(await session.transaction('begin', { signal: controller.signal }));
210
241
  }
211
- const executed = await session.execute(target.sql, { schema: activeSchema, limit: PAGE_SIZE, signal: controller.signal });
242
+ const executed = await session.execute(target, { schema: activeSchema, limit: PAGE_SIZE, signal: controller.signal });
212
243
  if (controller.signal.aborted) {
213
244
  setRun({ status: 'cancelled' });
214
245
  return;
@@ -235,17 +266,24 @@ export function QueryConsole({
235
266
  const pickHistory = (entry: HistoryEntry, runIt: boolean): void => {
236
267
  changeSql(entry.sql);
237
268
  if (runIt) {
238
- requestRun({ sql: entry.sql, ran: { kind: 'statement', label: statementLabel(entry.sql) } });
269
+ requestRun(entry.sql);
239
270
  }
240
271
  };
241
272
 
242
273
  const settle = async (action: 'commit' | 'rollback'): Promise<boolean> => {
274
+ if (session === null) {
275
+ return false;
276
+ }
243
277
  setSettling(true);
244
278
  try {
245
279
  setInTransaction(await session.transaction(action));
246
280
  return true;
247
281
  } catch (error) {
248
- setNotice({ tone: 'error', message: messageOf(error) });
282
+ notify({
283
+ tone: 'error',
284
+ title: t(action === 'commit' ? 'console.transaction.commitFailed' : 'console.transaction.rollbackFailed'),
285
+ description: messageOf(error)
286
+ });
249
287
  return false;
250
288
  } finally {
251
289
  setSettling(false);
@@ -268,6 +306,9 @@ export function QueryConsole({
268
306
  };
269
307
 
270
308
  const loadPage = async (index: number, result: StatementResult, offset: number): Promise<void> => {
309
+ if (session === null) {
310
+ return;
311
+ }
271
312
  const mine = generation.current;
272
313
  setPaging(true);
273
314
  setNotice(null);
@@ -277,14 +318,14 @@ export function QueryConsole({
277
318
  setPages((current) => ({ ...current, [index]: { offset, rows } }));
278
319
  }
279
320
  } catch (error) {
280
- setNotice({ tone: 'error', message: messageOf(error) });
321
+ notify({ tone: 'error', title: t('console.pageFailed'), description: messageOf(error) });
281
322
  } finally {
282
323
  setPaging(false);
283
324
  }
284
325
  };
285
326
 
286
327
  const exportResult = async (result: StatementResult, format: FileFormat): Promise<void> => {
287
- if (files === undefined) {
328
+ if (files === undefined || session === null) {
288
329
  return;
289
330
  }
290
331
  setExporting(true);
@@ -292,10 +333,10 @@ export function QueryConsole({
292
333
  const path = await files.save({ suggestedName: `result.${format}`, format });
293
334
  if (path !== null) {
294
335
  const exported = await session.export({ source: { kind: 'query', sql: result.sql, schema: activeSchema }, format, path });
295
- setNotice({ tone: 'neutral', message: t('console.exported', { count: exported.rows, formatted: formatNumber(exported.rows) }) });
336
+ notify({ tone: 'success', title: t('console.exported', { count: exported.rows, formatted: formatNumber(exported.rows) }) });
296
337
  }
297
338
  } catch (error) {
298
- setNotice({ tone: 'error', message: messageOf(error) });
339
+ notify({ tone: 'error', title: t('console.exportFailed'), description: messageOf(error) });
299
340
  } finally {
300
341
  setExporting(false);
301
342
  }
@@ -308,7 +349,7 @@ export function QueryConsole({
308
349
  const shown: StatementResult | undefined = base?.kind === 'rows' && page !== undefined ? { kind: 'rows', sql: base.sql, ...page.rows } : base;
309
350
  const offset = page?.offset ?? 0;
310
351
  const pager: ResultPager | undefined =
311
- base?.kind === 'rows' && shown?.kind === 'rows' && isPageable(base.sql, engine) && (shown.hasMore || offset > 0)
352
+ base?.kind === 'rows' && shown?.kind === 'rows' && engine !== undefined && isPageable(base.sql, engine) && (shown.hasMore || offset > 0)
312
353
  ? {
313
354
  page: offset / PAGE_SIZE + 1,
314
355
  loading: paging,
@@ -317,102 +358,162 @@ export function QueryConsole({
317
358
  }
318
359
  : undefined;
319
360
  const exportBase = files !== undefined && base?.kind === 'rows' ? base : undefined;
361
+ // Read for the statement, not for each page of it, so paging through a result asks for nothing again.
362
+ const keys = useResultKeys(session, engine, base);
363
+
364
+ const editorProps: QueryConsoleEditorProps = {
365
+ ref: editorHandle,
366
+ value: sql,
367
+ onValueChange: changeSql,
368
+ run: (scope) => requestRun(targetOf(scope)),
369
+ busy,
370
+ label: t('console.editor'),
371
+ placeholder: t('console.placeholder'),
372
+ autoFocus
373
+ };
320
374
 
321
375
  return (
322
376
  <div ref={ref} className={clsx('flex min-h-0 flex-col bg-surface text-text', className)}>
323
- <div className="flex shrink-0 border-b border-border">
324
- <div className="flex min-w-0 flex-1 flex-col gap-2 p-3">
325
- <SqlEditor
326
- ref={editor}
327
- value={sql}
328
- onValueChange={changeSql}
329
- onRun={(scope) => requestRun(targetOf(scope))}
330
- label={t('console.editor')}
331
- placeholder={t('console.placeholder')}
332
- />
333
- <div className="flex flex-wrap items-center gap-2">
334
- {run.status === 'running' ? (
335
- <>
336
- <Button size="sm" onClick={() => running.current?.abort()}>
337
- <Icon icon={Square} size={12} />
338
- {t('console.cancel')}
339
- </Button>
340
- <Spinner size={14} label={t('console.running')} />
341
- </>
342
- ) : (
377
+ <div className="flex min-h-0 flex-1">
378
+ <div className="flex min-w-0 flex-1 flex-col">
379
+ {/* The app's editor runs edge to edge, the way it draws a file; the text area keeps the inset of a field. */}
380
+ <div className={clsx('flex min-h-0 flex-1 flex-col', renderEditor === undefined && 'px-3 pt-3')}>
381
+ {renderEditor === undefined ? <SqlEditor {...editorProps} /> : <AppEditor render={renderEditor} {...editorProps} />}
382
+ </div>
383
+ <div
384
+ className={clsx(
385
+ 'flex shrink-0 flex-wrap items-center gap-2 px-3 pb-3',
386
+ renderEditor === undefined ? 'pt-2' : 'border-t border-border pt-3'
387
+ )}
388
+ >
389
+ {session !== null && (
343
390
  <>
344
- <Tooltip label={t('console.run')} kbd={RUN_SHORTCUT}>
345
- <Button variant="primary" size="sm" disabled={blank || busy} onClick={() => requestRun(targetOf('selection-or-statement'))}>
346
- <Icon icon={Play} size={12} />
347
- {t('console.run')}
348
- </Button>
349
- </Tooltip>
350
- <Tooltip label={t('console.runAll')} kbd={RUN_ALL_SHORTCUT}>
351
- <Button variant="secondary" size="sm" disabled={blank || busy} onClick={() => requestRun(targetOf('all'))}>
352
- {t('console.runAll')}
353
- </Button>
354
- </Tooltip>
391
+ {run.status === 'running' ? (
392
+ <>
393
+ <Button size="sm" onClick={() => running.current?.abort()}>
394
+ <Icon icon={Square} size={12} />
395
+ {t('console.cancel')}
396
+ </Button>
397
+ <Spinner size={14} label={t('console.running')} />
398
+ </>
399
+ ) : (
400
+ <>
401
+ <Tooltip label={t('console.run')} kbd={RUN_SHORTCUT}>
402
+ <Button
403
+ variant="primary"
404
+ size="sm"
405
+ disabled={blank || busy}
406
+ onClick={() => requestRun(targetOf('selection-or-statement'))}
407
+ >
408
+ <Icon icon={Play} size={12} />
409
+ {t('console.run')}
410
+ </Button>
411
+ </Tooltip>
412
+ <Tooltip label={t('console.runAll')} kbd={RUN_ALL_SHORTCUT}>
413
+ <Button variant="secondary" size="sm" disabled={blank || busy} onClick={() => requestRun(targetOf('all'))}>
414
+ {t('console.runAll')}
415
+ </Button>
416
+ </Tooltip>
417
+ </>
418
+ )}
419
+ <TransactionControls
420
+ mode={mode}
421
+ onModeChange={changeMode}
422
+ open={inTransaction}
423
+ busy={busy}
424
+ onCommit={() => void settle('commit')}
425
+ onRollback={() => void settle('rollback')}
426
+ />
355
427
  </>
356
428
  )}
357
- <TransactionControls
358
- mode={mode}
359
- onModeChange={changeMode}
360
- open={inTransaction}
361
- busy={busy}
362
- onCommit={() => void settle('commit')}
363
- onRollback={() => void settle('rollback')}
364
- />
365
- {ran !== null && run.status !== 'idle' && <span className="min-w-0 truncate text-xs text-text-muted">{ranText(ran, t)}</span>}
366
429
  <div className="ml-auto flex items-center gap-2">
367
- {schemas.length > 0 && (
368
- <SchemaPicker schemas={schemas} value={activeSchema ?? null} disabled={busy} onValueChange={setPickedSchema} />
430
+ {toolbarEnd}
431
+ {session !== null && schemas.length > 0 && (
432
+ <SchemaPicker
433
+ schemas={schemas}
434
+ value={activeSchema ?? null}
435
+ disabled={busy}
436
+ onValueChange={onSchemaChange ?? setPickedSchema}
437
+ />
438
+ )}
439
+ {session !== null && (
440
+ <Button
441
+ variant="secondary"
442
+ size="sm"
443
+ aria-pressed={historyOpen}
444
+ className="aria-pressed:bg-surface-active aria-pressed:text-text"
445
+ onClick={() => setHistoryOpen(!historyOpen)}
446
+ >
447
+ <Icon icon={History} size={12} />
448
+ {t('console.history.title')}
449
+ </Button>
369
450
  )}
370
- <Button
371
- size="sm"
372
- aria-pressed={historyOpen}
373
- className="aria-pressed:bg-surface-active aria-pressed:text-text"
374
- onClick={() => setHistoryOpen(!historyOpen)}
375
- >
376
- <Icon icon={History} size={12} />
377
- {t('console.history.title')}
378
- </Button>
379
451
  </div>
380
452
  </div>
381
453
  </div>
382
- {historyOpen && <HistoryPanel entries={history.entries} onPick={pickHistory} onClear={history.clear} />}
454
+ {session !== null && historyOpen && <HistoryPanel entries={history.entries} onPick={pickHistory} onClear={history.clear} />}
383
455
  </div>
384
- {run.status === 'failed' && <Banner icon={CircleAlert} tone="error" message={run.message} className="shrink-0 pt-2" />}
385
- {run.status === 'cancelled' && <Banner icon={CircleAlert} tone="neutral" message={t('console.cancelled')} className="shrink-0 pt-2" />}
386
- {notice !== null && (
387
- <Banner icon={notice.tone === 'error' ? CircleAlert : Check} tone={notice.tone} message={notice.message} className="shrink-0 pt-2">
456
+ {session !== null && notice !== null && (
457
+ <Banner
458
+ icon={notice.tone === 'error' ? CircleAlert : Check}
459
+ tone={notice.tone === 'error' ? 'error' : 'neutral'}
460
+ message={notice.description ?? notice.title}
461
+ className="shrink-0 pb-2"
462
+ >
388
463
  <Button size="xs" onClick={() => setNotice(null)}>
389
464
  {t('console.dismiss')}
390
465
  </Button>
391
466
  </Banner>
392
467
  )}
393
- {results.length > 1 && (
394
- <Tabs.Root value={tab} onValueChange={(next) => setTab(Number(next))}>
395
- <Tabs.List className="shrink-0 px-3">
396
- {results.map((result, position) => (
397
- <Tabs.Tab key={position} value={position}>
398
- {statementLabel(result.sql)}
399
- </Tabs.Tab>
400
- ))}
401
- </Tabs.List>
402
- </Tabs.Root>
403
- )}
404
- {shown !== undefined && (
405
- <StatementResultView
406
- key={`${index}:${offset}`}
407
- result={shown}
408
- offset={offset}
409
- pager={pager}
410
- onExport={exportBase === undefined ? undefined : (format) => void exportResult(exportBase, format)}
411
- exporting={exporting}
412
- engine={engine}
413
- valuePanelOpen={panelOpen}
414
- onValuePanelOpenChange={setPanelOpen}
415
- />
468
+ {session !== null && resultsOpen && (
469
+ <section
470
+ ref={resultsPane}
471
+ aria-label={t('console.results')}
472
+ className="relative flex min-h-24 shrink-0 flex-col border-t border-border"
473
+ style={{ height: resultsHeight ?? '50%', maxHeight: `calc(100% - ${MIN_EDITOR_HEIGHT}px)` }}
474
+ >
475
+ <ColumnResizeHandle from="bottom" onPointerDown={startResize} />
476
+ <Tabs.Root value={index} onValueChange={(next) => setTab(Number(next))}>
477
+ <Tabs.List
478
+ aria-label={t('console.results')}
479
+ className="shrink-0 pr-1.5 pl-3"
480
+ end={<IconButton icon={X} size="sm" label={t('console.closeResults')} onClick={() => setResultsOpen(false)} />}
481
+ >
482
+ {results.map((result, position) => (
483
+ <Tabs.Tab key={position} value={position}>
484
+ {statementLabel(result.sql)}
485
+ </Tabs.Tab>
486
+ ))}
487
+ </Tabs.List>
488
+ </Tabs.Root>
489
+ {run.status === 'failed' && <Banner icon={CircleAlert} tone="error" message={run.message} className="shrink-0 pt-2" />}
490
+ {run.status === 'cancelled' && <Banner icon={CircleAlert} tone="neutral" message={t('console.cancelled')} className="shrink-0 pt-2" />}
491
+ {shown !== undefined && (
492
+ <StatementResultView
493
+ key={`${index}:${offset}`}
494
+ result={shown}
495
+ offset={offset}
496
+ pager={pager}
497
+ onExport={exportBase === undefined ? undefined : (format) => void exportResult(exportBase, format)}
498
+ exporting={exporting}
499
+ engine={engine}
500
+ valuePanelOpen={panelOpen}
501
+ onValuePanelOpenChange={setPanelOpen}
502
+ keys={keys}
503
+ onFollowReference={
504
+ act === undefined || connection === undefined
505
+ ? undefined
506
+ : (reference) =>
507
+ act({
508
+ kind: 'open-table',
509
+ ref: { connectionId: connection.id, schema: reference.schema, table: reference.table },
510
+ view: 'data',
511
+ where: reference.where
512
+ })
513
+ }
514
+ />
515
+ )}
516
+ </section>
416
517
  )}
417
518
  <DestructiveDialog
418
519
  statements={confirming?.statements ?? []}