@rebasepro/studio 0.24.0 → 0.24.1-canary.gcb918fc

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 (48) hide show
  1. package/dist/{ApiExplorer-CCtczj3F.js → ApiExplorer-Bzt7Ye49.js} +5 -3
  2. package/dist/ApiExplorer-Bzt7Ye49.js.map +1 -0
  3. package/dist/{ApiKeysView-B66TdzhH.js → ApiKeysView-Bqf6XYR1.js} +2 -1
  4. package/dist/{ApiKeysView-B66TdzhH.js.map → ApiKeysView-Bqf6XYR1.js.map} +1 -1
  5. package/dist/{BackupsView-DmSMzAbT.js → BackupsView-BhOHB6iP.js} +6 -20
  6. package/dist/BackupsView-BhOHB6iP.js.map +1 -0
  7. package/dist/{BranchesView-DP1a87E-.js → BranchesView-BHfwGTYc.js} +5 -3
  8. package/dist/BranchesView-BHfwGTYc.js.map +1 -0
  9. package/dist/{CronJobsView-TDErHTuK.js → CronJobsView-CMNK7eWO.js} +52 -59
  10. package/dist/CronJobsView-CMNK7eWO.js.map +1 -0
  11. package/dist/{JSEditor-B59pwg4c.js → JSEditor-DIO2Mw8q.js} +115 -96
  12. package/dist/JSEditor-DIO2Mw8q.js.map +1 -0
  13. package/dist/{LogsExplorer-BKM4yfP8.js → LogsExplorer-C-bwDFIl.js} +19 -23
  14. package/dist/LogsExplorer-C-bwDFIl.js.map +1 -0
  15. package/dist/{RLSEditor-BlLLV6xs.js → RLSEditor-jFD_d06f.js} +10 -8
  16. package/dist/RLSEditor-jFD_d06f.js.map +1 -0
  17. package/dist/{SQLEditor-1An7w0j6.js → SQLEditor-D5Ci4pyW.js} +372 -545
  18. package/dist/SQLEditor-D5Ci4pyW.js.map +1 -0
  19. package/dist/{SchemaVisualizer-DCLVfA5t.js → SchemaVisualizer-D-rAqwul.js} +479 -206
  20. package/dist/SchemaVisualizer-D-rAqwul.js.map +1 -0
  21. package/dist/{StorageView-CbsHuCBf.js → StorageView-CPkDKbsX.js} +498 -241
  22. package/dist/StorageView-CPkDKbsX.js.map +1 -0
  23. package/dist/assets/dagre-layout.worker-BFnhAzuR.js.map +1 -0
  24. package/dist/components/SQLEditor/SQLEditor.d.ts +11 -0
  25. package/dist/components/SchemaVisualizer/dagre-layout.d.ts +32 -0
  26. package/dist/components/SchemaVisualizer/dagre-layout.worker.d.ts +13 -0
  27. package/dist/components/SchemaVisualizer/layout-runner.d.ts +23 -0
  28. package/dist/components/SchemaVisualizer/schema-visualizer.utils.d.ts +21 -10
  29. package/dist/components/SchemaVisualizer/useSchemaGraph.d.ts +38 -1
  30. package/dist/components/StorageView/useVisibleFileMetadata.d.ts +46 -0
  31. package/dist/dagre-layout.worker-j9NbH9Gb.js +27 -0
  32. package/dist/dagre-layout.worker-j9NbH9Gb.js.map +1 -0
  33. package/dist/index.es.js +16 -14
  34. package/dist/index.es.js.map +1 -1
  35. package/dist/sql_utils-BZD31xE3.js +478 -0
  36. package/dist/sql_utils-BZD31xE3.js.map +1 -0
  37. package/dist/utils/sql_utils.d.ts +60 -0
  38. package/package.json +9 -9
  39. package/dist/ApiExplorer-CCtczj3F.js.map +0 -1
  40. package/dist/BackupsView-DmSMzAbT.js.map +0 -1
  41. package/dist/BranchesView-DP1a87E-.js.map +0 -1
  42. package/dist/CronJobsView-TDErHTuK.js.map +0 -1
  43. package/dist/JSEditor-B59pwg4c.js.map +0 -1
  44. package/dist/LogsExplorer-BKM4yfP8.js.map +0 -1
  45. package/dist/RLSEditor-BlLLV6xs.js.map +0 -1
  46. package/dist/SQLEditor-1An7w0j6.js.map +0 -1
  47. package/dist/SchemaVisualizer-DCLVfA5t.js.map +0 -1
  48. package/dist/StorageView-CbsHuCBf.js.map +0 -1
@@ -1,11 +1,11 @@
1
+ import { a as needsDestructiveConfirmation, c as rememberColumnWidth, d as resultsToCsv, i as keepLatestColumnWidths, l as resolveCellEdit, m as withAutoLimit, n as formatSql, o as quoteIdentifier, p as typedJsonRows, r as groupSchemaRows, s as quoteTableName, t as buildExplainSql, u as resolveQueryCollections } from "./sql_utils-BZD31xE3.js";
1
2
  import { t as MonacoEditor } from "./MonacoEditor-CUxy5_k5.js";
2
- import { ConfirmationDialog, ErrorView, IconForView, useRebaseContext, useSnackbarController, useStudioCollectionRegistry, useStudioSidePanelController, useTranslation } from "@rebasepro/app";
3
+ import { ConfirmationDialog, ErrorView, IconForView, useApiBase, useApiConfig, useRebaseContext, useSnackbarController, useStudioCollectionRegistry, useStudioSidePanelController, useTranslation } from "@rebasepro/app";
3
4
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
- import { Alert, Button, Checkbox, CircularProgress, Collapse, CopyIcon, DatabaseIcon, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Menu, MenuIcon, MenuItem, MoreVerticalIcon, PencilIcon, PlayIcon, PlusIcon, RefreshCwIcon, ResizablePanels, Tab, Tabs, TerminalIcon, TextField, TextareaAutosize, Tooltip, Trash2Icon, Typography, VirtualTable, XIcon, cls, defaultBorderMixin, iconSize, isKeyHandled } from "@rebasepro/ui";
5
+ import { Alert, Button, Checkbox, Chip, CircularProgress, Collapse, CopyIcon, DatabaseIcon, Dialog, DialogActions, DialogContent, DialogTitle, HeartIcon, IconButton, Menu, MenuIcon, MenuItem, MoreVerticalIcon, PencilIcon, PlayIcon, PlusIcon, RefreshCwIcon, ResizablePanels, SquareIcon, Tab, Tabs, TerminalIcon, TextField, TextareaAutosize, Tooltip, Trash2Icon, Typography, VirtualTable, XIcon, cls, defaultBorderMixin, iconSize, isKeyHandled } from "@rebasepro/ui";
5
6
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
6
- import { isArrayValue, isRecordValue, readStoredJson, readStoredString, toSnakeCase, writeStoredJson, writeStoredString } from "@rebasepro/utils";
7
+ import { isArrayValue, isRecordValue, readStoredJson, readStoredString, writeStoredJson, writeStoredString } from "@rebasepro/utils";
7
8
  import { createPortal } from "react-dom";
8
- import { astVisitor, parse } from "pgsql-ast-parser";
9
9
  //#region src/components/SQLEditor/SchemaBrowser.tsx
10
10
  var SchemaBrowser = ({ onTableClick, schemas, isSchemaLoading, schemaError, onRetrySchema }) => {
11
11
  const [expandedSchemas, setExpandedSchemas] = useState({ public: true });
@@ -31,9 +31,10 @@ var SchemaBrowser = ({ onTableClick, schemas, isSchemaLoading, schemaError, onRe
31
31
  className: "font-semibold uppercase tracking-wider text-text-disabled dark:text-text-disabled-dark",
32
32
  children: t("studio_schema_tables")
33
33
  }), /* @__PURE__ */ jsx(IconButton, {
34
+ "aria-label": t("refresh"),
34
35
  size: "small",
35
36
  onClick: onRetrySchema,
36
- title: "Refresh schema",
37
+ title: t("refresh"),
37
38
  children: /* @__PURE__ */ jsx(RefreshCwIcon, { size: iconSize.smallest })
38
39
  })]
39
40
  }), /* @__PURE__ */ jsx("div", {
@@ -99,19 +100,21 @@ var SchemaBrowser = ({ onTableClick, schemas, isSchemaLoading, schemaError, onRe
99
100
  className: "flex opacity-0 group-hover:opacity-100 focus-within:opacity-100 absolute right-1 items-center bg-surface-raised px-1 gap-1 rounded transition-opacity",
100
101
  children: [
101
102
  /* @__PURE__ */ jsx(IconButton, {
103
+ "aria-label": t("copy_table_name"),
102
104
  size: "small",
103
105
  onClick: (e) => {
104
106
  e.stopPropagation();
105
107
  navigator.clipboard.writeText(table.tableName);
106
108
  },
107
- title: "Copy table name",
109
+ title: t("copy_table_name"),
108
110
  children: /* @__PURE__ */ jsx(CopyIcon, { size: iconSize.small })
109
111
  }),
110
112
  /* @__PURE__ */ jsxs(Menu, {
111
113
  trigger: /* @__PURE__ */ jsx(IconButton, {
114
+ "aria-label": t("generate_sql_templates"),
112
115
  size: "small",
113
116
  onClick: (e) => e.stopPropagation(),
114
- title: "Generate SQL templates",
117
+ title: t("generate_sql_templates"),
115
118
  children: /* @__PURE__ */ jsx(MoreVerticalIcon, { size: iconSize.small })
116
119
  }),
117
120
  children: [
@@ -195,13 +198,14 @@ var SchemaBrowser = ({ onTableClick, schemas, isSchemaLoading, schemaError, onRe
195
198
  children: col.dataType
196
199
  }),
197
200
  /* @__PURE__ */ jsx(IconButton, {
201
+ "aria-label": t("copy_column_name"),
198
202
  size: "smallest",
199
203
  className: "opacity-0 group-hover:opacity-100 absolute right-1 bg-surface-sheet transition-colors pointer-events-auto",
200
204
  onClick: (e) => {
201
205
  e.stopPropagation();
202
206
  navigator.clipboard.writeText(col.name);
203
207
  },
204
- title: "Copy column name",
208
+ title: t("copy_column_name"),
205
209
  children: /* @__PURE__ */ jsx(CopyIcon, { size: iconSize.smallest })
206
210
  })
207
211
  ]
@@ -301,6 +305,7 @@ var SQLEditorSidebar = ({ onSelectSnippet, onTableClick, snippets, history, onDe
301
305
  children: snippet.sql
302
306
  }),
303
307
  /* @__PURE__ */ jsx(IconButton, {
308
+ "aria-label": t("studio_sql_sidebar_delete_snippet"),
304
309
  size: "smallest",
305
310
  className: "absolute right-1 top-1 opacity-0 group-hover:opacity-100 text-text-disabled hover:text-red-500 transition-opacity",
306
311
  onClick: (e) => {
@@ -333,6 +338,7 @@ var SQLEditorSidebar = ({ onSelectSnippet, onTableClick, snippets, history, onDe
333
338
  children: snippet.sql
334
339
  }),
335
340
  /* @__PURE__ */ jsx(IconButton, {
341
+ "aria-label": t("studio_sql_sidebar_delete_snippet"),
336
342
  size: "smallest",
337
343
  className: "absolute right-1 top-1 opacity-0 group-hover:opacity-100 text-text-disabled hover:text-red-500 transition-opacity",
338
344
  onClick: (e) => {
@@ -392,335 +398,6 @@ var SQLEditorSidebar = ({ onSelectSnippet, onTableClick, snippets, history, onDe
392
398
  });
393
399
  };
394
400
  //#endregion
395
- //#region src/utils/sql_utils.ts
396
- /** The statements in `sqlText`, or `null` when the parser cannot read it. */
397
- function parseStatements(sqlText) {
398
- try {
399
- return parse(sqlText);
400
- } catch {
401
- return null;
402
- }
403
- }
404
- /**
405
- * The `EXPLAIN` for one statement, or `null` when `sqlText` is not exactly one.
406
- *
407
- * Never `ANALYZE`: that executes the statement to time it, so explaining a
408
- * `DELETE` deleted the rows — without the confirmation "Run" asks for.
409
- *
410
- * And never more than one statement. Sent as one simple query, `EXPLAIN (…)
411
- * SELECT 1; DELETE …` explains the SELECT and *runs* the DELETE. Text the
412
- * parser cannot read is still explained, as long as no `;` could be hiding a
413
- * second statement in it.
414
- */
415
- function buildExplainSql(sqlText) {
416
- const statement = sqlText.trim().replace(/;+$/, "").trim();
417
- if (!statement) return null;
418
- const statements = parseStatements(statement);
419
- return (statements ? statements.length === 1 : !statement.includes(";")) ? `EXPLAIN (FORMAT JSON) ${statement}` : null;
420
- }
421
- /**
422
- * Whether the console's automatic `LIMIT` may be appended to `sqlText`: one
423
- * top-level SELECT that has no limit of its own.
424
- *
425
- * Decided on the parsed statement, not the text. A search for the word SELECT
426
- * also found it inside `INSERT INTO … SELECT` and `CREATE TABLE … AS SELECT`,
427
- * which then copied a thousand rows and reported success, and at the start of
428
- * a script whose last statement the appended `LIMIT` turned into a syntax
429
- * error. Text the parser cannot read is left as written.
430
- */
431
- function acceptsAutoLimit(sqlText) {
432
- const statements = parseStatements(sqlText);
433
- if (!statements || statements.length !== 1) return false;
434
- const [statement] = statements;
435
- return statement.type === "select" && !statement.limit;
436
- }
437
- /** A destructive command named anywhere in a text, comments included. */
438
- var DESTRUCTIVE_COMMAND = /\b(DELETE|UPDATE|DROP|TRUNCATE)\b/i;
439
- function isDestructiveStatement(statement) {
440
- switch (statement.type) {
441
- case "delete":
442
- case "update": return !statement.where;
443
- case "truncate table":
444
- case "drop table":
445
- case "drop sequence":
446
- case "drop index":
447
- case "drop type":
448
- case "drop trigger":
449
- case "drop function": return true;
450
- case "alter table": return statement.changes.some((change) => change.type === "drop column" || change.type === "drop constraint");
451
- case "with": return statement.bind.some((binding) => isDestructiveStatement(binding.statement)) || isDestructiveStatement(statement.in);
452
- case "with recursive": return isDestructiveStatement(statement.in);
453
- case "do": return DESTRUCTIVE_COMMAND.test(statement.code);
454
- default: return false;
455
- }
456
- }
457
- /**
458
- * Whether "Run" asks before running `sqlText`: a statement in it deletes or
459
- * updates without a WHERE of its own, drops or truncates.
460
- *
461
- * Decided per parsed statement, not on the text. A search of the text for the
462
- * word WHERE found it in another statement or in a comment, so `DELETE FROM
463
- * posts; SELECT * FROM posts WHERE id = 1` deleted every post without asking.
464
- * Text the parser cannot read is confirmed whenever it names a destructive
465
- * command.
466
- */
467
- function needsDestructiveConfirmation(sqlText) {
468
- const statements = parseStatements(sqlText);
469
- if (!statements) return DESTRUCTIVE_COMMAND.test(sqlText);
470
- return statements.some(isDestructiveStatement);
471
- }
472
- /**
473
- * A table as SQL: `"schema"."table"` when a schema is given, a bare `"table"`
474
- * otherwise.
475
- */
476
- function quoteTableName(tableName, schemaName) {
477
- return schemaName ? `${quoteIdentifier(schemaName)}.${quoteIdentifier(tableName)}` : quoteIdentifier(tableName);
478
- }
479
- /** An identifier as SQL, whatever it holds: double-quoted, with `"` doubled. */
480
- function quoteIdentifier(identifier) {
481
- return `"${identifier.replace(/"/g, "\"\"")}"`;
482
- }
483
- var refuse = (key, params = {}) => ({ refusal: {
484
- key,
485
- params
486
- } });
487
- /**
488
- * Which stored cell an edit of the result column `resultColumn` writes, and
489
- * how its row is found again — or why no edit is safe.
490
- *
491
- * Decided on what the database reported, not on the query's text. Each
492
- * column of the result says which table column it was read from, or that it
493
- * was computed; a cell is editable only when it was read from a column of a
494
- * table, every column of that table's primary key was read too, and each of
495
- * them is the only column of the result with its name — a row holds one value
496
- * per name.
497
- *
498
- * The text was the only evidence before, and it was wrong twice over. For
499
- * `SELECT p.id, a.name FROM posts p JOIN authors a …` it took the post's `id`
500
- * for the author's key and updated whichever author shared the post's id.
501
- * For `SELECT id, lower(email) AS name FROM users` it wrote the edited text
502
- * to `users.name`.
503
- *
504
- * The database reports a table, not which mention of it: in a self-join both
505
- * sides are `authors`, and a key from one with a value from the other finds
506
- * the wrong row. So the query is still read, for one thing — the table must
507
- * be read once, and nothing it is read through may be read twice.
508
- */
509
- function resolveCellEdit(sqlText, provenance, resultColumn) {
510
- const sameName = provenance.columns.filter((column) => column.name === resultColumn);
511
- if (sameName.length !== 1) return refuse("studio_sql_edit_duplicate_column", { column: resultColumn });
512
- const source = sameName[0].source;
513
- if (!source) return refuse("studio_sql_edit_computed_column", { column: resultColumn });
514
- const tableLabel = `${source.schema}.${source.table}`;
515
- const table = provenance.tables.find((t) => t.schema === source.schema && t.table === source.table);
516
- if (!table || table.kind !== "table" && table.kind !== "partitioned table") return refuse("studio_sql_edit_not_a_table", {
517
- column: resultColumn,
518
- table: tableLabel
519
- });
520
- if (table.hasInheritors) return refuse("studio_sql_edit_inherited_table", { table: tableLabel });
521
- if (table.primaryKey.length === 0) return refuse("studio_sql_edit_no_primary_key", { table: tableLabel });
522
- const key = [];
523
- for (const keyColumn of table.primaryKey) {
524
- const holders = provenance.columns.filter((column) => column.source?.schema === source.schema && column.source.table === source.table && column.source.column === keyColumn);
525
- const holder = holders.length === 1 && provenance.columns.filter((column) => column.name === holders[0].name).length === 1 ? holders[0] : void 0;
526
- if (!holder) return refuse("studio_sql_edit_key_missing", {
527
- table: tableLabel,
528
- columns: table.primaryKey.join(", ")
529
- });
530
- key.push({
531
- column: keyColumn,
532
- resultColumn: holder.name
533
- });
534
- }
535
- const readOnce = tableReadOnce(sqlText, source.table);
536
- if (readOnce !== true) return readOnce;
537
- return { target: {
538
- schema: source.schema,
539
- table: source.table,
540
- column: source.column,
541
- key
542
- } };
543
- }
544
- /**
545
- * Whether `sqlText` is one SELECT that reads `table` once — counting every
546
- * mention of a table of that name, in any schema and at any depth, and every
547
- * mention of a `WITH` query, since one read twice reads its tables twice.
548
- */
549
- function tableReadOnce(sqlText, table) {
550
- const statements = parseStatements(sqlText);
551
- if (!statements) return refuse("studio_sql_edit_unreadable_query");
552
- if (statements.length !== 1) return refuse("studio_sql_edit_not_a_select");
553
- const [statement] = statements;
554
- if (!(statement.type === "select" || statement.type === "with" && statement.in.type === "select" && statement.bind.every((binding) => binding.statement.type === "select"))) return refuse("studio_sql_edit_not_a_select");
555
- const mentions = /* @__PURE__ */ new Map();
556
- const withNames = [];
557
- astVisitor((visitor) => ({
558
- fromTable: (from) => {
559
- const name = from.name.name.toLowerCase();
560
- mentions.set(name, (mentions.get(name) ?? 0) + 1);
561
- visitor.super().fromTable(from);
562
- },
563
- with: (withStatement) => {
564
- withNames.push(...withStatement.bind.map((binding) => binding.alias.name.toLowerCase()));
565
- visitor.super().with(withStatement);
566
- }
567
- })).statement(statement);
568
- return (mentions.get(table.toLowerCase()) ?? 0) !== 1 || withNames.some((name) => (mentions.get(name) ?? 0) > 1) ? refuse("studio_sql_edit_table_read_twice", { table }) : true;
569
- }
570
- /**
571
- * The collections a result's rows can be opened as: those whose table one of
572
- * the result's columns was read from, with the table's (single-column) primary
573
- * key read too, under a name no other column of the result has.
574
- *
575
- * Matched on the provenance the database reported. A text match took the
576
- * result's `id` for every table of a join: `SELECT p.id, a.name FROM posts p
577
- * JOIN authors a …` offered to open the author whose id was the post's.
578
- */
579
- function resolveQueryCollections(provenance, collections) {
580
- const results = [];
581
- for (const table of provenance.tables) {
582
- const collection = collections.find((c) => {
583
- const tableName = ("table" in c ? c.table : void 0) || toSnakeCase(c.slug);
584
- const schemaName = ("schema" in c ? c.schema : void 0) || "public";
585
- return tableName === table.table && schemaName === table.schema;
586
- });
587
- if (!collection || table.primaryKey.length !== 1) continue;
588
- const [keyColumn] = table.primaryKey;
589
- const holders = provenance.columns.filter((column) => column.source?.schema === table.schema && column.source.table === table.table && column.source.column === keyColumn);
590
- if (holders.length !== 1) continue;
591
- const pkColumn = holders[0].name;
592
- if (provenance.columns.filter((column) => column.name === pkColumn).length !== 1) continue;
593
- results.push({
594
- tableName: table.table,
595
- schemaName: table.schema,
596
- collection,
597
- pkColumn
598
- });
599
- }
600
- return results;
601
- }
602
- /** A character an unquoted identifier or keyword can hold, `$` included. */
603
- var IDENTIFIER_CHARACTER = /[A-Za-z0-9_$]/;
604
- /**
605
- * Cut `sqlText` into code and the things in it whose text is data.
606
- *
607
- * A lexer, not a parser: it reads text the parser refuses as well, which is
608
- * most of what a person types while writing a query.
609
- */
610
- function splitSql(sqlText) {
611
- const segments = [];
612
- let code = "";
613
- const push = (text, kind) => {
614
- if (code) segments.push({
615
- text: code,
616
- kind: "code"
617
- });
618
- code = "";
619
- segments.push({
620
- text,
621
- kind
622
- });
623
- };
624
- let i = 0;
625
- while (i < sqlText.length) {
626
- const ch = sqlText[i];
627
- const next = sqlText[i + 1];
628
- if (ch === "-" && next === "-") {
629
- const newline = sqlText.indexOf("\n", i);
630
- const end = newline === -1 ? sqlText.length : newline + 1;
631
- push(sqlText.slice(i, end), "line comment");
632
- i = end;
633
- continue;
634
- }
635
- if (ch === "/" && next === "*") {
636
- let depth = 1;
637
- let j = i + 2;
638
- while (j < sqlText.length && depth > 0) if (sqlText[j] === "/" && sqlText[j + 1] === "*") {
639
- depth++;
640
- j += 2;
641
- } else if (sqlText[j] === "*" && sqlText[j + 1] === "/") {
642
- depth--;
643
- j += 2;
644
- } else j++;
645
- push(sqlText.slice(i, j), "block comment");
646
- i = j;
647
- continue;
648
- }
649
- if (ch === "'" || ch === "\"") {
650
- const backslashEscapes = ch === "'" && /(^|[^A-Za-z0-9_$])[eE]$/.test(code);
651
- let j = i + 1;
652
- while (j < sqlText.length) if (backslashEscapes && sqlText[j] === "\\") j += 2;
653
- else if (sqlText[j] === ch && sqlText[j + 1] === ch) j += 2;
654
- else if (sqlText[j] === ch) {
655
- j++;
656
- break;
657
- } else j++;
658
- push(sqlText.slice(i, j), ch === "'" ? "literal" : "identifier");
659
- i = j;
660
- continue;
661
- }
662
- if (ch === "$" && !IDENTIFIER_CHARACTER.test(code.slice(-1))) {
663
- const tag = /^\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/.exec(sqlText.slice(i));
664
- if (tag) {
665
- const close = sqlText.indexOf(tag[0], i + tag[0].length);
666
- const end = close === -1 ? sqlText.length : close + tag[0].length;
667
- push(sqlText.slice(i, end), "literal");
668
- i = end;
669
- continue;
670
- }
671
- }
672
- code += ch;
673
- i++;
674
- }
675
- if (code) segments.push({
676
- text: code,
677
- kind: "code"
678
- });
679
- return segments;
680
- }
681
- /**
682
- * The console's "Format SQL": runs of whitespace become one space, a comma is
683
- * followed by one, and an `=` standing alone gets one on each side — in the
684
- * code only.
685
- *
686
- * A literal, a quoted identifier and a comment are copied as they are: their
687
- * text is data, and rewriting `'a,b'` to `'a, b'` changes the value the next
688
- * Run writes. A line comment keeps the line break that ends it, or it would
689
- * swallow the code after it. `>=`, `<=`, `!=`, `:=` and `=>` are operators of
690
- * their own and are left whole.
691
- */
692
- function formatSql(sqlText) {
693
- const segments = splitSql(sqlText);
694
- return segments.map((segment, index) => {
695
- if (segment.kind !== "code") return segment.text;
696
- const tidied = segment.text.replace(/\s+/g, " ").replace(/\s*,\s*/g, ", ").replace(/(?<![<>!:=~*+\-/%^&|#@?])\s*=\s*(?![=>~*<])/g, " = ");
697
- return segments[index - 1]?.kind === "line comment" ? tidied.trimStart() : tidied;
698
- }).join("").trim();
699
- }
700
- /**
701
- * `sqlText` with the console's automatic `LIMIT` on it, or `null` when it
702
- * takes none (see {@link acceptsAutoLimit}).
703
- *
704
- * The limit goes straight after the statement's last token — before a
705
- * trailing comment, which would otherwise swallow it, and in place of a
706
- * trailing `;`. A query that computes an aggregate is limited like any other:
707
- * the limit applies to the rows it returns, so a `count(*)` still counts every
708
- * row, and a window `count(*) OVER ()` is no longer a way past the cap.
709
- */
710
- function withAutoLimit(sqlText, limit) {
711
- if (!acceptsAutoLimit(sqlText)) return null;
712
- const segments = splitSql(sqlText);
713
- const isComment = (segment) => segment.kind === "line comment" || segment.kind === "block comment";
714
- const isFiller = (segment) => isComment(segment) || segment.kind === "code" && /^[\s;]*$/.test(segment.text);
715
- let last = segments.length - 1;
716
- while (last >= 0 && isFiller(segments[last])) last--;
717
- if (last < 0) return null;
718
- const lastSegment = segments[last];
719
- const head = lastSegment.kind === "code" ? lastSegment.text.replace(/[\s;]+$/, "") : lastSegment.text;
720
- const tail = (lastSegment.kind === "code" ? lastSegment.text.slice(head.length) : "").replace(/;/g, "") + segments.slice(last + 1).map((segment) => segment.kind === "code" ? segment.text.replace(/;/g, "") : segment.text).join("");
721
- return `${segments.slice(0, last).map((segment) => segment.text).join("")}${head} LIMIT ${limit};${tail.trimEnd()}`;
722
- }
723
- //#endregion
724
401
  //#region src/components/SQLEditor/ExplainVisualizer.tsx
725
402
  var ExplainVisualizer = ({ plan, isRoot = true }) => {
726
403
  const [expanded, setExpanded] = useState(true);
@@ -864,6 +541,11 @@ var ExplainVisualizer = ({ plan, isRoot = true }) => {
864
541
  };
865
542
  //#endregion
866
543
  //#region src/components/SQLEditor/SQLEditor.tsx
544
+ /**
545
+ * The box a result cell is edited in. It closes once: Enter saves, Escape
546
+ * cancels, a click away saves — and the blur a closing box can still receive
547
+ * is not a second answer, which made Enter save twice and Escape save at all.
548
+ */
867
549
  var FixedEditorOverlay = ({ displayValue, onSave, onCancel }) => {
868
550
  const [rect, setRect] = useState(null);
869
551
  const [windowSize, setWindowSize] = useState({
@@ -871,6 +553,14 @@ var FixedEditorOverlay = ({ displayValue, onSave, onCancel }) => {
871
553
  height: 1e3
872
554
  });
873
555
  const anchorRef = useRef(null);
556
+ const closedRef = useRef(false);
557
+ /** Close the box, saving `typed` when it is given. Only the first call counts. */
558
+ const close = (typed) => {
559
+ if (closedRef.current) return;
560
+ closedRef.current = true;
561
+ if (typed !== void 0) onSave(typed);
562
+ onCancel();
563
+ };
874
564
  useEffect(() => {
875
565
  if (anchorRef.current && anchorRef.current.parentElement) setRect(anchorRef.current.parentElement.getBoundingClientRect());
876
566
  if (typeof window !== "undefined") {
@@ -923,17 +613,13 @@ var FixedEditorOverlay = ({ displayValue, onSave, onCancel }) => {
923
613
  e.target.value = "";
924
614
  e.target.value = val;
925
615
  },
926
- onBlur: (e) => {
927
- onSave(e.target.value);
928
- onCancel();
929
- },
616
+ onBlur: (e) => close(e.target.value),
930
617
  onKeyDown: (e) => {
931
618
  if (e.key === "Enter" && !e.shiftKey) {
932
619
  e.preventDefault();
933
- onSave(e.currentTarget.value);
934
- onCancel();
620
+ close(e.currentTarget.value);
935
621
  }
936
- if (e.key === "Escape") onCancel();
622
+ if (e.key === "Escape") close();
937
623
  }
938
624
  })
939
625
  }), document.body)
@@ -1000,7 +686,8 @@ var SQLEditor = () => {
1000
686
  lastExecutedConnection: null,
1001
687
  ranAs: null,
1002
688
  lastProvenance: null,
1003
- notices: []
689
+ notices: [],
690
+ truncatedFrom: null
1004
691
  }));
1005
692
  if (restored.length > 0) return restored;
1006
693
  return [{
@@ -1017,7 +704,8 @@ var SQLEditor = () => {
1017
704
  lastExecutedConnection: null,
1018
705
  ranAs: null,
1019
706
  lastProvenance: null,
1020
- notices: []
707
+ notices: [],
708
+ truncatedFrom: null
1021
709
  }];
1022
710
  });
1023
711
  const [activeTabId, setActiveTabId] = useState(() => {
@@ -1035,6 +723,12 @@ var SQLEditor = () => {
1035
723
  const loading = activeTab.loading;
1036
724
  const error = activeTab.error;
1037
725
  const execTime = activeTab.execTime;
726
+ /**
727
+ * The SQL whose rows are on screen, which their column widths are kept
728
+ * under. Not the text being typed: keyed by that, every keystroke handed
729
+ * the grid other widths, and every visible cell was drawn again.
730
+ */
731
+ const resultSql = activeTab.lastExecutedSql ?? "";
1038
732
  const setSql = (newSql) => updateActiveTab({ sql: newSql });
1039
733
  useEffect(() => {
1040
734
  let mounted = true;
@@ -1178,30 +872,7 @@ var SQLEditor = () => {
1178
872
  c.table_schema, c.table_name, c.ordinal_position;
1179
873
  `, { database: selectedDatabase });
1180
874
  const processGrouped = (data) => {
1181
- const grouped = data.reduce((acc, curr) => {
1182
- const schema = curr.schema || curr.SCHEMA || curr.table_schema || "public";
1183
- const table = curr.table || curr.TABLE || curr.table_name;
1184
- const column = curr.column || curr.COLUMN || curr.column_name;
1185
- const dataType = curr.data_type || curr.DATA_TYPE || "";
1186
- const isPrimaryKey = curr.is_pk === true || curr.is_pk === "true";
1187
- if (!acc[schema]) acc[schema] = [];
1188
- let tableInfo = acc[schema].find((t) => t.tableName === table);
1189
- if (!tableInfo) {
1190
- tableInfo = {
1191
- schemaName: schema,
1192
- tableName: table,
1193
- columns: []
1194
- };
1195
- acc[schema].push(tableInfo);
1196
- }
1197
- tableInfo.columns.push({
1198
- name: column,
1199
- dataType,
1200
- isPrimaryKey
1201
- });
1202
- return acc;
1203
- }, {});
1204
- setSchemas(grouped);
875
+ setSchemas(groupSchemaRows(data));
1205
876
  };
1206
877
  if (!result || !Array.isArray(result)) if (result && typeof result === "object" && "rows" in result && Array.isArray(result.rows)) processGrouped(result.rows);
1207
878
  else {
@@ -1362,10 +1033,10 @@ var SQLEditor = () => {
1362
1033
  t
1363
1034
  ]);
1364
1035
  const [columnWidths, setColumnWidths] = useState(() => {
1365
- return readStoredJson(`rebase_sql_column_widths_${client?.baseUrl ? client.baseUrl.replace(/^https?:\/\//, "").replace(/[^a-zA-Z0-9]/g, "_") : "default"}`, {
1036
+ return keepLatestColumnWidths(readStoredJson(`rebase_sql_column_widths_${client?.baseUrl ? client.baseUrl.replace(/^https?:\/\//, "").replace(/[^a-zA-Z0-9]/g, "_") : "default"}`, {
1366
1037
  fallback: {},
1367
1038
  accept: isRecordValue
1368
- });
1039
+ }));
1369
1040
  });
1370
1041
  const [snippets, setSnippets] = useState([]);
1371
1042
  const [history, setHistory] = useState([]);
@@ -1430,7 +1101,8 @@ var SQLEditor = () => {
1430
1101
  lastExecutedConnection: null,
1431
1102
  ranAs: null,
1432
1103
  lastProvenance: null,
1433
- notices: []
1104
+ notices: [],
1105
+ truncatedFrom: null
1434
1106
  }]);
1435
1107
  setActiveTabId(newId);
1436
1108
  };
@@ -1447,22 +1119,46 @@ var SQLEditor = () => {
1447
1119
  };
1448
1120
  const handleColumnResize = useCallback(({ key, width }) => {
1449
1121
  setColumnWidths((prev) => {
1450
- const newWidths = {
1451
- ...prev,
1452
- [activeTab.sql]: {
1453
- ...prev[activeTab.sql] || {},
1454
- [key]: width
1455
- }
1456
- };
1122
+ const newWidths = rememberColumnWidth(prev, resultSql, key, width);
1457
1123
  writeStoredJson(`rebase_sql_column_widths_${projectPrefix}`, newWidths);
1458
1124
  return newWidths;
1459
1125
  });
1460
- }, [activeTab.sql, projectPrefix]);
1126
+ }, [resultSql, projectPrefix]);
1461
1127
  const handlePrettify = () => {
1462
1128
  setSql(formatSql(activeTab.sql));
1463
1129
  };
1464
1130
  /** The editor, for the selection the toolbar's buttons act on. */
1465
1131
  const editorHandle = useRef(null);
1132
+ /**
1133
+ * The tabs with a run or an explain in flight. A ref, not the tab's
1134
+ * `loading`: two Cmd+Enter in a row both read the render before the first
1135
+ * set it, and only the button was disabled — so an INSERT ran twice, and
1136
+ * the slower answer drew over the newer one.
1137
+ */
1138
+ const tabsRunningRef = useRef(/* @__PURE__ */ new Set());
1139
+ /**
1140
+ * Each tab's run in flight, by tab, for "Cancel run": aborting it rejects
1141
+ * the run at once, and a server that can cancels the statement too.
1142
+ */
1143
+ const runAbortRef = useRef(/* @__PURE__ */ new Map());
1144
+ const [cancellableTabs, setCancellableTabs] = useState(/* @__PURE__ */ new Set());
1145
+ const setCancellable = (tabId, cancellable) => setCancellableTabs((previous) => {
1146
+ const next = new Set(previous);
1147
+ if (cancellable) next.add(tabId);
1148
+ else next.delete(tabId);
1149
+ return next;
1150
+ });
1151
+ const handleCancelRun = () => runAbortRef.current.get(activeTab.id)?.abort();
1152
+ /** `work` on the active tab, unless that tab is already running something. */
1153
+ const runOnce = async (tabId, work) => {
1154
+ if (tabsRunningRef.current.has(tabId)) return;
1155
+ tabsRunningRef.current.add(tabId);
1156
+ try {
1157
+ await work();
1158
+ } finally {
1159
+ tabsRunningRef.current.delete(tabId);
1160
+ }
1161
+ };
1466
1162
  const handleExplain = async () => {
1467
1163
  const explainSql = buildExplainSql(editorHandle.current?.getSelectedText() ?? activeTab.sql);
1468
1164
  if (!explainSql) {
@@ -1472,6 +1168,9 @@ var SQLEditor = () => {
1472
1168
  });
1473
1169
  return;
1474
1170
  }
1171
+ await runOnce(activeTab.id, () => explain(explainSql));
1172
+ };
1173
+ const explain = async (explainSql) => {
1475
1174
  updateActiveTab({
1476
1175
  loading: true,
1477
1176
  error: null,
@@ -1480,7 +1179,8 @@ var SQLEditor = () => {
1480
1179
  lastExecutedConnection: null,
1481
1180
  ranAs: null,
1482
1181
  lastProvenance: null,
1483
- notices: []
1182
+ notices: [],
1183
+ truncatedFrom: null
1484
1184
  });
1485
1185
  const start = performance.now();
1486
1186
  try {
@@ -1501,23 +1201,33 @@ var SQLEditor = () => {
1501
1201
  updateActiveTab({ loading: false });
1502
1202
  }
1503
1203
  };
1504
- const executeRun = useCallback(async (sqlOverride) => {
1204
+ const executeRun = useCallback((sqlOverride) => runOnce(activeTab.id, async () => {
1505
1205
  const written = sqlOverride || activeTab.sql;
1506
1206
  const sqlToRun = (autoLimit ? withAutoLimit(written, 1e3) : null) ?? written;
1507
1207
  updateActiveTab({
1508
1208
  loading: true,
1509
1209
  error: null,
1510
1210
  results: null,
1511
- notices: []
1211
+ notices: [],
1212
+ truncatedFrom: null
1512
1213
  });
1513
1214
  const start = performance.now();
1215
+ const tabId = activeTab.id;
1216
+ const abort = databaseAdmin?.runSqlScript ? new AbortController() : void 0;
1217
+ if (abort) {
1218
+ runAbortRef.current.set(tabId, abort);
1219
+ setCancellable(tabId, true);
1220
+ }
1514
1221
  try {
1515
1222
  if (databaseAdmin?.runSqlScript || databaseAdmin?.executeSql) {
1516
1223
  const connection = {
1517
1224
  database: selectedDatabase,
1518
1225
  role: selectedRole
1519
1226
  };
1520
- const run = databaseAdmin.runSqlScript ? await databaseAdmin.runSqlScript(sqlToRun, connection) : void 0;
1227
+ const run = databaseAdmin.runSqlScript ? await databaseAdmin.runSqlScript(sqlToRun, {
1228
+ ...connection,
1229
+ signal: abort?.signal
1230
+ }) : void 0;
1521
1231
  const rows = run ? run.rows : await databaseAdmin.executeSql(sqlToRun, connection);
1522
1232
  updateActiveTab({
1523
1233
  results: rows,
@@ -1532,18 +1242,24 @@ var SQLEditor = () => {
1532
1242
  columns: Object.keys(rows[0] ?? {}).map((name) => ({ name })),
1533
1243
  tables: []
1534
1244
  },
1535
- notices: run?.notices ?? []
1245
+ notices: run?.notices ?? [],
1246
+ truncatedFrom: run?.truncated ? run.rowCount ?? null : null
1536
1247
  });
1537
1248
  if (run?.effectiveRole && connection.role) setConnectionOwner(run.effectiveRole !== connection.role ? run.effectiveRole : null);
1538
1249
  if (history[history.length - 1] !== activeTab.sql) saveHistory([...history, activeTab.sql]);
1539
1250
  } else updateActiveTab({ error: t("studio_sql_execution_not_supported") });
1540
1251
  } catch (e) {
1541
1252
  const message = e instanceof Error ? e.message : String(e);
1542
- updateActiveTab({ error: message || t("studio_sql_error_executing") });
1253
+ updateActiveTab({ error: abort?.signal.aborted ? t("studio_sql_run_cancelled") : message || t("studio_sql_error_executing") });
1543
1254
  } finally {
1255
+ if (abort && runAbortRef.current.get(tabId) === abort) {
1256
+ runAbortRef.current.delete(tabId);
1257
+ setCancellable(tabId, false);
1258
+ }
1544
1259
  updateActiveTab({ loading: false });
1545
1260
  }
1546
- }, [
1261
+ }), [
1262
+ activeTab.id,
1547
1263
  activeTab.sql,
1548
1264
  autoLimit,
1549
1265
  databaseAdmin,
@@ -1595,11 +1311,7 @@ var SQLEditor = () => {
1595
1311
  };
1596
1312
  const handleExportCSV = () => {
1597
1313
  if (!results || results.length === 0) return;
1598
- const csv = [Object.keys(results[0]).join(","), ...results.map((row) => Object.values(row).map((val) => {
1599
- const str = String(val);
1600
- return str.includes(",") ? `"${str}"` : str;
1601
- }).join(","))].join("\n");
1602
- const blob = new Blob([csv], { type: "text/csv" });
1314
+ const blob = new Blob([resultsToCsv(results)], { type: "text/csv" });
1603
1315
  const url = window.URL.createObjectURL(blob);
1604
1316
  const a = document.createElement("a");
1605
1317
  a.href = url;
@@ -1607,9 +1319,71 @@ var SQLEditor = () => {
1607
1319
  a.click();
1608
1320
  window.URL.revokeObjectURL(url);
1609
1321
  };
1322
+ const apiConfig = useApiConfig();
1323
+ const apiBase = useApiBase();
1324
+ const [downloadingFullResult, setDownloadingFullResult] = useState(false);
1325
+ /**
1326
+ * The whole result of the last run, past the rows the console keeps:
1327
+ * streamed by the server through a read-only cursor (`POST
1328
+ * /admin/sql/csv`), on the database and as the role the rows on screen
1329
+ * were read with.
1330
+ */
1331
+ const handleDownloadFullCsv = async () => {
1332
+ const query = activeTab.lastExecutedSql;
1333
+ const connection = activeTab.lastExecutedConnection;
1334
+ if (!query || !apiBase) return;
1335
+ setDownloadingFullResult(true);
1336
+ try {
1337
+ const token = apiConfig?.getAuthToken ? await apiConfig.getAuthToken() : null;
1338
+ const response = await fetch(`${apiBase}/admin/sql/csv`, {
1339
+ method: "POST",
1340
+ headers: {
1341
+ "Content-Type": "application/json",
1342
+ ...token ? { Authorization: `Bearer ${token}` } : {}
1343
+ },
1344
+ body: JSON.stringify({
1345
+ sql: query,
1346
+ database: connection?.database,
1347
+ role: connection?.role
1348
+ })
1349
+ });
1350
+ if (!response.ok) {
1351
+ const failure = await response.json().catch(() => null);
1352
+ const reason = typeof failure === "object" && failure !== null && "error" in failure && typeof failure.error === "object" && failure.error !== null && "message" in failure.error && typeof failure.error.message === "string" ? failure.error.message : `HTTP ${response.status}`;
1353
+ throw new Error(reason);
1354
+ }
1355
+ const blob = await response.blob();
1356
+ const url = window.URL.createObjectURL(blob);
1357
+ const a = document.createElement("a");
1358
+ a.href = url;
1359
+ a.download = `query_results_${(/* @__PURE__ */ new Date()).toISOString().slice(0, 19)}.csv`;
1360
+ a.click();
1361
+ window.URL.revokeObjectURL(url);
1362
+ } catch (e) {
1363
+ snackbarController.open({
1364
+ type: "error",
1365
+ message: t("studio_sql_download_full_failed", { message: e instanceof Error ? e.message : String(e) })
1366
+ });
1367
+ } finally {
1368
+ setDownloadingFullResult(false);
1369
+ }
1370
+ };
1610
1371
  const handleExportJSON = () => {
1611
1372
  if (!results || results.length === 0) return;
1612
- const json = JSON.stringify(results, null, 2);
1373
+ downloadJson(results);
1374
+ };
1375
+ /**
1376
+ * The result's columns say their types when the script door ran it: each
1377
+ * value read by its type (numbers, booleans, json), where Export JSON
1378
+ * writes the database's text as the grid holds it.
1379
+ */
1380
+ const typedColumns = activeTab.lastProvenance?.columns.some((column) => column.type !== void 0) ? activeTab.lastProvenance.columns : null;
1381
+ const handleExportTypedJSON = () => {
1382
+ if (!results || results.length === 0 || !typedColumns) return;
1383
+ downloadJson(typedJsonRows(results, typedColumns));
1384
+ };
1385
+ const downloadJson = (rows) => {
1386
+ const json = JSON.stringify(rows, null, 2);
1613
1387
  const blob = new Blob([json], { type: "application/json" });
1614
1388
  const url = window.URL.createObjectURL(blob);
1615
1389
  const a = document.createElement("a");
@@ -1643,6 +1417,135 @@ var SQLEditor = () => {
1643
1417
  });
1644
1418
  });
1645
1419
  };
1420
+ const savedWidths = columnWidths[resultSql];
1421
+ const resultColumnKeys = useMemo(() => results && results.length > 0 ? Object.keys(results[0]) : [], [results]);
1422
+ const readFromAppDatabase = activeTab.lastExecutedConnection !== null && (activeTab.lastExecutedConnection.database === void 0 || activeTab.lastExecutedConnection.database === appDatabase);
1423
+ const lastProvenance = activeTab.lastProvenance;
1424
+ const registryCollections = collectionRegistry.collections;
1425
+ const actionableCollections = useMemo(() => lastProvenance && registryCollections && readFromAppDatabase ? resolveQueryCollections(lastProvenance, registryCollections).filter((mc) => resultColumnKeys.includes(mc.pkColumn)) : [], [
1426
+ lastProvenance,
1427
+ registryCollections,
1428
+ readFromAppDatabase,
1429
+ resultColumnKeys
1430
+ ]);
1431
+ const getRowEntityActions = useCallback((rowData) => {
1432
+ if (!rowData) return [];
1433
+ return actionableCollections.filter((mc) => rowData[mc.pkColumn] != null).map((mc) => ({
1434
+ collection: mc,
1435
+ entityId: rowData[mc.pkColumn]
1436
+ }));
1437
+ }, [actionableCollections]);
1438
+ const hasRowActions = actionableCollections.length > 0;
1439
+ const resultColumns = useMemo(() => {
1440
+ const dataColumns = resultColumnKeys.map((key) => ({
1441
+ key,
1442
+ title: key,
1443
+ width: savedWidths?.[key] ?? 150,
1444
+ sortable: false,
1445
+ resizable: true
1446
+ }));
1447
+ return hasRowActions ? [{
1448
+ key: "__cms_action__",
1449
+ title: "",
1450
+ width: 36,
1451
+ sortable: false,
1452
+ resizable: false
1453
+ }, ...dataColumns] : dataColumns;
1454
+ }, [
1455
+ resultColumnKeys,
1456
+ savedWidths,
1457
+ hasRowActions
1458
+ ]);
1459
+ const renderResultCell = useCallback(({ rowData, column, rowIndex }) => {
1460
+ if (column.key === "__cms_action__") {
1461
+ const rowActions = getRowEntityActions(rowData ?? {});
1462
+ if (rowActions.length === 0) return /* @__PURE__ */ jsx("div", { className: "h-full w-full" });
1463
+ if (rowActions.length === 1) {
1464
+ const ra = rowActions[0];
1465
+ return /* @__PURE__ */ jsx("div", {
1466
+ className: "h-full flex items-center justify-center",
1467
+ children: /* @__PURE__ */ jsx(Tooltip, {
1468
+ title: t("studio_sql_edit_entity", {
1469
+ name: ra.collection.collection.name,
1470
+ id: String(ra.entityId)
1471
+ }),
1472
+ children: /* @__PURE__ */ jsx(IconButton, {
1473
+ size: "small",
1474
+ "aria-label": t("studio_sql_edit_entity", {
1475
+ name: ra.collection.collection.name,
1476
+ id: String(ra.entityId)
1477
+ }),
1478
+ className: "text-surface-400 dark:text-surface-500 hover:text-surface-600 dark:hover:text-surface-300 transition-colors",
1479
+ onClick: (e) => {
1480
+ e.stopPropagation();
1481
+ sidePanelController?.open({
1482
+ path: ra.collection.collection.slug,
1483
+ entityId: ra.entityId,
1484
+ collection: ra.collection.collection,
1485
+ updateUrl: false
1486
+ });
1487
+ },
1488
+ children: /* @__PURE__ */ jsx(PencilIcon, { size: iconSize.smallest })
1489
+ })
1490
+ })
1491
+ });
1492
+ }
1493
+ return /* @__PURE__ */ jsx("div", {
1494
+ className: "h-full flex items-center justify-center",
1495
+ children: /* @__PURE__ */ jsx(Menu, {
1496
+ trigger: /* @__PURE__ */ jsx(IconButton, {
1497
+ "aria-label": t("more_actions"),
1498
+ size: "small",
1499
+ className: "text-surface-400 dark:text-surface-500 hover:text-surface-600 dark:hover:text-surface-300 transition-colors",
1500
+ onClick: (e) => e.stopPropagation(),
1501
+ children: /* @__PURE__ */ jsx(MoreVerticalIcon, { size: iconSize.smallest })
1502
+ }),
1503
+ children: rowActions.map((ra) => /* @__PURE__ */ jsx(MenuItem, {
1504
+ dense: true,
1505
+ onClick: () => {
1506
+ sidePanelController?.open({
1507
+ path: ra.collection.collection.slug,
1508
+ entityId: ra.entityId,
1509
+ collection: ra.collection.collection,
1510
+ updateUrl: false
1511
+ });
1512
+ },
1513
+ children: t("studio_sql_edit_entity", {
1514
+ name: ra.collection.collection.name,
1515
+ id: String(ra.entityId)
1516
+ })
1517
+ }, ra.collection.tableName))
1518
+ })
1519
+ });
1520
+ }
1521
+ const isEditing = editingCell?.rowIndex === rowIndex && editingCell?.columnKey === column.key;
1522
+ const value = rowData ? rowData[column.key] : null;
1523
+ const displayValue = typeof value === "object" && value !== null ? JSON.stringify(value) : String(value ?? "");
1524
+ if (isEditing) return /* @__PURE__ */ jsx(FixedEditorOverlay, {
1525
+ displayValue,
1526
+ onSave: (val) => handleCellSave(val, rowData ?? {}, column.key, rowIndex),
1527
+ onCancel: () => setEditingCell(null)
1528
+ });
1529
+ return /* @__PURE__ */ jsx("div", {
1530
+ className: "px-4 py-1.5 h-full flex items-center whitespace-nowrap text-[13px] text-text-primary dark:text-text-primary-dark font-mono cursor-text group/cell",
1531
+ onDoubleClick: () => handleDoubleClick(rowIndex, column.key, displayValue, rowData ?? {}),
1532
+ children: /* @__PURE__ */ jsx("div", {
1533
+ className: "truncate flex-grow",
1534
+ title: displayValue,
1535
+ children: value === null || value === void 0 ? /* @__PURE__ */ jsx("span", {
1536
+ className: "text-text-disabled dark:text-text-disabled-dark italic text-[11px]",
1537
+ children: "NULL"
1538
+ }) : displayValue
1539
+ })
1540
+ });
1541
+ }, [
1542
+ getRowEntityActions,
1543
+ editingCell,
1544
+ handleCellSave,
1545
+ handleDoubleClick,
1546
+ sidePanelController,
1547
+ t
1548
+ ]);
1646
1549
  const renderResults = () => {
1647
1550
  if (loading) return /* @__PURE__ */ jsx("div", {
1648
1551
  className: "flex-grow flex items-center justify-center",
@@ -1666,17 +1569,10 @@ var SQLEditor = () => {
1666
1569
  className: "flex-grow flex items-center justify-center text-text-disabled dark:text-text-disabled-dark",
1667
1570
  children: /* @__PURE__ */ jsxs("div", {
1668
1571
  className: "text-center",
1669
- children: [/* @__PURE__ */ jsx("svg", {
1670
- className: "w-12 h-12 mx-auto mb-4 opacity-50",
1671
- fill: "none",
1672
- stroke: "currentColor",
1673
- viewBox: "0 0 24 24",
1674
- children: /* @__PURE__ */ jsx("path", {
1675
- strokeLinecap: "round",
1676
- strokeLinejoin: "round",
1677
- strokeWidth: 1,
1678
- d: "M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"
1679
- })
1572
+ children: [/* @__PURE__ */ jsx(DatabaseIcon, {
1573
+ size: 48,
1574
+ strokeWidth: 1,
1575
+ className: "mx-auto mb-4 opacity-50"
1680
1576
  }), /* @__PURE__ */ jsx(Typography, {
1681
1577
  variant: "body2",
1682
1578
  children: t("studio_sql_run_query_placeholder")
@@ -1688,8 +1584,9 @@ var SQLEditor = () => {
1688
1584
  if (plan) return /* @__PURE__ */ jsxs("div", {
1689
1585
  className: "flex-grow overflow-auto p-4 bg-surface-sheet flex flex-col items-start",
1690
1586
  children: [/* @__PURE__ */ jsx(Typography, {
1691
- variant: "caption",
1692
- className: "font-semibold text-text-secondary mb-4 tracking-wider uppercase",
1587
+ variant: "label",
1588
+ color: "secondary",
1589
+ className: "mb-4",
1693
1590
  children: t("studio_sql_visual_execution_plan")
1694
1591
  }), /* @__PURE__ */ jsx("div", {
1695
1592
  className: "pb-12",
@@ -1711,31 +1608,6 @@ var SQLEditor = () => {
1711
1608
  children: t("studio_sql_no_results")
1712
1609
  })]
1713
1610
  });
1714
- const savedWidths = columnWidths[activeTab.sql] || {};
1715
- const resultColumnKeys = Object.keys(results[0]);
1716
- const readFromAppDatabase = activeTab.lastExecutedConnection !== null && (activeTab.lastExecutedConnection.database === void 0 || activeTab.lastExecutedConnection.database === appDatabase);
1717
- const actionableCollections = activeTab.lastProvenance && collectionRegistry.collections && readFromAppDatabase ? resolveQueryCollections(activeTab.lastProvenance, collectionRegistry.collections).filter((mc) => resultColumnKeys.includes(mc.pkColumn)) : [];
1718
- const getRowEntityActions = (rowData) => {
1719
- if (!rowData) return [];
1720
- return actionableCollections.filter((mc) => rowData[mc.pkColumn] != null).map((mc) => ({
1721
- collection: mc,
1722
- entityId: rowData[mc.pkColumn]
1723
- }));
1724
- };
1725
- const dataColumns = resultColumnKeys.map((key) => ({
1726
- key,
1727
- title: key,
1728
- width: savedWidths[key] ?? 150,
1729
- sortable: false,
1730
- resizable: true
1731
- }));
1732
- const columns = actionableCollections.length > 0 ? [{
1733
- key: "__cms_action__",
1734
- title: "",
1735
- width: 36,
1736
- sortable: false,
1737
- resizable: false
1738
- }, ...dataColumns] : dataColumns;
1739
1611
  return /* @__PURE__ */ jsxs("div", {
1740
1612
  className: "flex-grow flex flex-col overflow-hidden min-h-0",
1741
1613
  children: [
@@ -1745,19 +1617,22 @@ var SQLEditor = () => {
1745
1617
  title: t("studio_sql_admin_collections_tooltip"),
1746
1618
  children: /* @__PURE__ */ jsx(Typography, {
1747
1619
  variant: "caption",
1748
- className: "text-[10px] font-semibold uppercase tracking-widest text-text-disabled dark:text-text-disabled-dark mr-1 shrink-0 cursor-help",
1620
+ color: "secondary",
1621
+ className: "mr-1 shrink-0 cursor-help",
1749
1622
  children: t("studio_sql_collections_label")
1750
1623
  })
1751
1624
  }), /* @__PURE__ */ jsx("div", {
1752
1625
  className: "flex items-center gap-1.5 overflow-x-auto no-scrollbar",
1753
1626
  children: actionableCollections.map((mc) => /* @__PURE__ */ jsx(Tooltip, {
1754
1627
  title: `Table "${mc.tableName}" → ${mc.collection.name} (PK: ${mc.pkColumn})`,
1755
- children: /* @__PURE__ */ jsxs("span", {
1756
- className: "inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-primary/10 dark:bg-primary-dark/15 text-primary dark:text-primary-dark whitespace-nowrap border border-primary/20 dark:border-primary-dark/20",
1757
- children: [typeof mc.collection.icon === "string" && /* @__PURE__ */ jsx(IconForView, {
1628
+ children: /* @__PURE__ */ jsx(Chip, {
1629
+ size: "smallest",
1630
+ className: "whitespace-nowrap",
1631
+ icon: typeof mc.collection.icon === "string" ? /* @__PURE__ */ jsx(IconForView, {
1758
1632
  collectionOrView: mc.collection,
1759
- className: "text-[12px]"
1760
- }), mc.collection.name]
1633
+ size: "smallest"
1634
+ }) : void 0,
1635
+ children: mc.collection.name
1761
1636
  })
1762
1637
  }, mc.tableName))
1763
1638
  })]
@@ -1766,93 +1641,12 @@ var SQLEditor = () => {
1766
1641
  className: "flex-grow relative h-full min-h-0 min-w-0",
1767
1642
  children: /* @__PURE__ */ jsx(VirtualTable, {
1768
1643
  data: results,
1769
- columns,
1644
+ columns: resultColumns,
1770
1645
  rowHeight: 32,
1771
1646
  headerHeight: 32,
1772
1647
  extraData: editingCell,
1773
1648
  onColumnResizeEnd: handleColumnResize,
1774
- cellRenderer: ({ rowData, column, rowIndex }) => {
1775
- if (column.key === "__cms_action__") {
1776
- const rowActions = getRowEntityActions(rowData ?? {});
1777
- if (rowActions.length === 0) return /* @__PURE__ */ jsx("div", { className: "h-full w-full" });
1778
- if (rowActions.length === 1) {
1779
- const ra = rowActions[0];
1780
- return /* @__PURE__ */ jsx("div", {
1781
- className: "h-full flex items-center justify-center",
1782
- children: /* @__PURE__ */ jsx(Tooltip, {
1783
- title: t("studio_sql_edit_entity", {
1784
- name: ra.collection.collection.name,
1785
- id: String(ra.entityId)
1786
- }),
1787
- children: /* @__PURE__ */ jsx(IconButton, {
1788
- size: "small",
1789
- "aria-label": t("studio_sql_edit_entity", {
1790
- name: ra.collection.collection.name,
1791
- id: String(ra.entityId)
1792
- }),
1793
- className: "text-surface-400 dark:text-surface-500 hover:text-surface-600 dark:hover:text-surface-300 transition-colors",
1794
- onClick: (e) => {
1795
- e.stopPropagation();
1796
- sidePanelController?.open({
1797
- path: ra.collection.collection.slug,
1798
- entityId: ra.entityId,
1799
- collection: ra.collection.collection,
1800
- updateUrl: false
1801
- });
1802
- },
1803
- children: /* @__PURE__ */ jsx(PencilIcon, { size: iconSize.smallest })
1804
- })
1805
- })
1806
- });
1807
- }
1808
- return /* @__PURE__ */ jsx("div", {
1809
- className: "h-full flex items-center justify-center",
1810
- children: /* @__PURE__ */ jsx(Menu, {
1811
- trigger: /* @__PURE__ */ jsx(IconButton, {
1812
- size: "small",
1813
- className: "text-surface-400 dark:text-surface-500 hover:text-surface-600 dark:hover:text-surface-300 transition-colors",
1814
- onClick: (e) => e.stopPropagation(),
1815
- children: /* @__PURE__ */ jsx(MoreVerticalIcon, { size: iconSize.smallest })
1816
- }),
1817
- children: rowActions.map((ra) => /* @__PURE__ */ jsx(MenuItem, {
1818
- dense: true,
1819
- onClick: () => {
1820
- sidePanelController?.open({
1821
- path: ra.collection.collection.slug,
1822
- entityId: ra.entityId,
1823
- collection: ra.collection.collection,
1824
- updateUrl: false
1825
- });
1826
- },
1827
- children: t("studio_sql_edit_entity", {
1828
- name: ra.collection.collection.name,
1829
- id: String(ra.entityId)
1830
- })
1831
- }, ra.collection.tableName))
1832
- })
1833
- });
1834
- }
1835
- const isEditing = editingCell?.rowIndex === rowIndex && editingCell?.columnKey === column.key;
1836
- const value = rowData ? rowData[column.key] : null;
1837
- const displayValue = typeof value === "object" && value !== null ? JSON.stringify(value) : String(value ?? "");
1838
- if (isEditing) return /* @__PURE__ */ jsx(FixedEditorOverlay, {
1839
- displayValue,
1840
- onSave: (val) => handleCellSave(val, rowData ?? {}, column.key, rowIndex),
1841
- onCancel: () => setEditingCell(null)
1842
- });
1843
- return /* @__PURE__ */ jsx("div", {
1844
- className: "px-4 py-1.5 h-full flex items-center whitespace-nowrap text-[13px] text-text-primary dark:text-text-primary-dark font-mono cursor-text group/cell",
1845
- onDoubleClick: () => handleDoubleClick(rowIndex, column.key, displayValue, rowData ?? {}),
1846
- children: /* @__PURE__ */ jsx("div", {
1847
- className: "truncate flex-grow",
1848
- title: displayValue,
1849
- children: value === null || value === void 0 ? /* @__PURE__ */ jsx("span", {
1850
- className: "text-text-disabled dark:text-text-disabled-dark italic text-[11px]",
1851
- children: "NULL"
1852
- }) : displayValue
1853
- })
1854
- });
1855
- }
1649
+ cellRenderer: renderResultCell
1856
1650
  })
1857
1651
  }),
1858
1652
  /* @__PURE__ */ jsxs("div", {
@@ -1860,21 +1654,25 @@ var SQLEditor = () => {
1860
1654
  children: [/* @__PURE__ */ jsxs("div", {
1861
1655
  className: "flex space-x-4",
1862
1656
  children: [/* @__PURE__ */ jsxs("div", {
1863
- className: "flex items-center text-[11px]",
1864
- children: [/* @__PURE__ */ jsx("span", {
1865
- className: "font-semibold text-text-disabled dark:text-text-disabled-dark mr-2 uppercase tracking-tighter",
1657
+ className: "flex items-center gap-2",
1658
+ children: [/* @__PURE__ */ jsx(Typography, {
1659
+ variant: "caption",
1660
+ color: "secondary",
1866
1661
  children: t("studio_sql_rows")
1867
- }), /* @__PURE__ */ jsx("span", {
1868
- className: "font-mono text-text-secondary dark:text-text-secondary-dark",
1662
+ }), /* @__PURE__ */ jsx(Typography, {
1663
+ variant: "mono",
1664
+ className: "text-[11px]",
1869
1665
  children: results.length
1870
1666
  })]
1871
1667
  }), /* @__PURE__ */ jsxs("div", {
1872
- className: "flex items-center text-[11px]",
1873
- children: [/* @__PURE__ */ jsx("span", {
1874
- className: "font-semibold text-text-disabled dark:text-text-disabled-dark mr-2 uppercase tracking-tighter",
1668
+ className: "flex items-center gap-2",
1669
+ children: [/* @__PURE__ */ jsx(Typography, {
1670
+ variant: "caption",
1671
+ color: "secondary",
1875
1672
  children: t("studio_sql_time")
1876
- }), /* @__PURE__ */ jsxs("span", {
1877
- className: "font-mono text-text-secondary dark:text-text-secondary-dark",
1673
+ }), /* @__PURE__ */ jsxs(Typography, {
1674
+ variant: "mono",
1675
+ className: "text-[11px]",
1878
1676
  children: [execTime, "ms"]
1879
1677
  })]
1880
1678
  })]
@@ -1884,21 +1682,28 @@ var SQLEditor = () => {
1884
1682
  /* @__PURE__ */ jsx(Button, {
1885
1683
  size: "small",
1886
1684
  variant: "text",
1887
- className: "text-[10px] uppercase font-semibold text-text-secondary dark:text-text-secondary-dark whitespace-nowrap",
1685
+ className: "whitespace-nowrap",
1888
1686
  onClick: handleExportMarkdown,
1889
1687
  children: t("studio_sql_copy_markdown")
1890
1688
  }),
1891
1689
  /* @__PURE__ */ jsx(Button, {
1892
1690
  size: "small",
1893
1691
  variant: "text",
1894
- className: "text-[10px] uppercase font-semibold text-text-secondary dark:text-text-secondary-dark whitespace-nowrap",
1692
+ className: "whitespace-nowrap",
1895
1693
  onClick: handleExportJSON,
1896
1694
  children: t("studio_sql_export_json")
1897
1695
  }),
1696
+ typedColumns && /* @__PURE__ */ jsx(Button, {
1697
+ size: "small",
1698
+ variant: "text",
1699
+ className: "whitespace-nowrap",
1700
+ onClick: handleExportTypedJSON,
1701
+ children: t("studio_sql_export_json_typed")
1702
+ }),
1898
1703
  /* @__PURE__ */ jsx(Button, {
1899
1704
  size: "small",
1900
1705
  variant: "text",
1901
- className: "text-[10px] uppercase font-semibold text-text-secondary dark:text-text-secondary-dark whitespace-nowrap",
1706
+ className: "whitespace-nowrap",
1902
1707
  onClick: handleExportCSV,
1903
1708
  children: t("studio_sql_export_csv")
1904
1709
  })
@@ -1927,6 +1732,7 @@ var SQLEditor = () => {
1927
1732
  className: "flex h-full w-full bg-surface-card overflow-hidden text-text-primary dark:text-text-primary-dark",
1928
1733
  children: [
1929
1734
  /* @__PURE__ */ jsx(ResizablePanels, {
1735
+ dividerAriaLabel: t("resize_sidebar"),
1930
1736
  orientation: "horizontal",
1931
1737
  panelSizePercent: sidebarSize,
1932
1738
  onPanelSizeChange: setSidebarSize,
@@ -2046,17 +1852,10 @@ var SQLEditor = () => {
2046
1852
  isFavorite: !s.isFavorite
2047
1853
  } : s));
2048
1854
  },
2049
- children: /* @__PURE__ */ jsx("svg", {
2050
- className: `w-4 h-4 ${isFavorite ? "text-red-500 fill-current" : "text-text-disabled dark:text-text-disabled-dark hover:text-text-primary"}`,
1855
+ children: /* @__PURE__ */ jsx(HeartIcon, {
1856
+ size: iconSize.smallest,
2051
1857
  fill: isFavorite ? "currentColor" : "none",
2052
- stroke: "currentColor",
2053
- strokeWidth: 2,
2054
- viewBox: "0 0 24 24",
2055
- children: /* @__PURE__ */ jsx("path", {
2056
- strokeLinecap: "round",
2057
- strokeLinejoin: "round",
2058
- d: "M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
2059
- })
1858
+ className: cls(isFavorite ? "text-red-500" : "text-text-disabled dark:text-text-disabled-dark hover:text-text-primary")
2060
1859
  })
2061
1860
  })
2062
1861
  }),
@@ -2136,10 +1935,20 @@ var SQLEditor = () => {
2136
1935
  size: iconSize.smallest,
2137
1936
  className: "mr-2"
2138
1937
  }), t("studio_sql_run")]
1938
+ }),
1939
+ loading && cancellableTabs.has(activeTab.id) && /* @__PURE__ */ jsxs(Button, {
1940
+ onClick: handleCancelRun,
1941
+ size: "small",
1942
+ variant: "text",
1943
+ children: [/* @__PURE__ */ jsx(SquareIcon, {
1944
+ size: iconSize.smallest,
1945
+ className: "mr-2"
1946
+ }), t("studio_sql_cancel_run")]
2139
1947
  })
2140
1948
  ]
2141
1949
  })]
2142
1950
  }), /* @__PURE__ */ jsx(ResizablePanels, {
1951
+ dividerAriaLabel: t("resize_editor"),
2143
1952
  orientation: "vertical",
2144
1953
  panelSizePercent: editorHeight,
2145
1954
  onPanelSizeChange: setEditorHeight,
@@ -2176,6 +1985,24 @@ var SQLEditor = () => {
2176
1985
  })
2177
1986
  })
2178
1987
  }),
1988
+ !loading && results && activeTab.truncatedFrom !== null && /* @__PURE__ */ jsx("div", {
1989
+ className: "px-4 pt-2 shrink-0",
1990
+ children: /* @__PURE__ */ jsx(Alert, {
1991
+ color: "info",
1992
+ size: "small",
1993
+ action: activeTab.lastExecutedSql && apiBase ? /* @__PURE__ */ jsx(Button, {
1994
+ size: "small",
1995
+ variant: "text",
1996
+ disabled: downloadingFullResult,
1997
+ onClick: handleDownloadFullCsv,
1998
+ children: downloadingFullResult ? t("studio_sql_downloading_full_csv") : t("studio_sql_download_full_csv")
1999
+ }) : void 0,
2000
+ children: t("studio_sql_result_truncated", {
2001
+ shown: results.length.toLocaleString(),
2002
+ total: activeTab.truncatedFrom.toLocaleString()
2003
+ })
2004
+ })
2005
+ }),
2179
2006
  !loading && activeTab.notices.length > 0 && /* @__PURE__ */ jsx("div", {
2180
2007
  className: "px-4 pt-2 flex flex-col gap-1 shrink-0",
2181
2008
  children: activeTab.notices.map((notice, index) => /* @__PURE__ */ jsx(Alert, {
@@ -2246,4 +2073,4 @@ var SQLEditor = () => {
2246
2073
  //#endregion
2247
2074
  export { SQLEditor };
2248
2075
 
2249
- //# sourceMappingURL=SQLEditor-1An7w0j6.js.map
2076
+ //# sourceMappingURL=SQLEditor-D5Ci4pyW.js.map