@tabularis/explain 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/react.js CHANGED
@@ -1,9 +1,9 @@
1
- import { memo, useState, useEffect, useCallback, useMemo } from 'react';
1
+ import { memo, useCallback, useState, useRef, useEffect, useMemo } from 'react';
2
2
  import { Handle, Position, ReactFlowProvider, useReactFlow, useNodesState, useEdgesState, ReactFlow, Background, Controls, MiniMap } from '@xyflow/react';
3
3
  import '@xyflow/react/dist/style.css';
4
4
  import dagre from 'dagre';
5
5
  import { useTranslation } from 'react-i18next';
6
- import { Network, BarChart3, TableProperties, PieChart, FileText, Sparkles, CircleSlash, DatabaseZap, Repeat, Users, Layers, ScanSearch, Filter, HardDrive, TrendingDown, TrendingUp, Flame, Info, AlertTriangle, ChevronDown, ChevronRight, Layers2, Clock3, Database, LayoutDashboard, BookOpenText } from 'lucide-react';
6
+ import { Network, BarChart3, TableProperties, PieChart, FileText, Sparkles, CircleSlash, DatabaseZap, Repeat, Users, Layers, ScanSearch, Filter, HardDrive, TrendingDown, TrendingUp, Flame, Info, AlertTriangle, Check, Copy, RotateCcw, Download, Layers2, Clock3, Database, LayoutDashboard, ChevronDown, ChevronRight, BookOpenText } from 'lucide-react';
7
7
  import clsx from 'clsx';
8
8
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
9
9
 
@@ -87,16 +87,10 @@ function computeExplainMetrics(plan) {
87
87
  let maxExclusiveBuffers = 0;
88
88
  let totalExclusiveTimeMs = 0;
89
89
  for (const metrics of order) {
90
- maxExclusiveTimeMs = Math.max(
91
- maxExclusiveTimeMs,
92
- metrics.exclusiveTimeMs ?? 0
93
- );
90
+ maxExclusiveTimeMs = Math.max(maxExclusiveTimeMs, metrics.exclusiveTimeMs ?? 0);
94
91
  maxExclusiveCost = Math.max(maxExclusiveCost, metrics.exclusiveCost ?? 0);
95
92
  maxTotalRows = Math.max(maxTotalRows, metrics.totalRows ?? 0);
96
- maxExclusiveBuffers = Math.max(
97
- maxExclusiveBuffers,
98
- metrics.exclusiveBuffers ?? 0
99
- );
93
+ maxExclusiveBuffers = Math.max(maxExclusiveBuffers, metrics.exclusiveBuffers ?? 0);
100
94
  totalExclusiveTimeMs += metrics.exclusiveTimeMs ?? 0;
101
95
  }
102
96
  if (totalExclusiveTimeMs > 0) {
@@ -295,7 +289,7 @@ function getRowEstimateRatio(node) {
295
289
  }
296
290
  return node.actual_rows / node.plan_rows;
297
291
  }
298
- function getMismatchMagnitude(node) {
292
+ function getRowEstimateGap(node) {
299
293
  const ratio = getRowEstimateRatio(node);
300
294
  if (ratio == null) {
301
295
  return null;
@@ -341,7 +335,7 @@ function getExplainPlanSummary(plan, metrics) {
341
335
  };
342
336
  }
343
337
  const ratio = getRowEstimateRatio(node);
344
- const magnitude = getMismatchMagnitude(node);
338
+ const magnitude = getRowEstimateGap(node);
345
339
  if (ratio != null && magnitude != null && (largestRowMismatchNode == null || magnitude > largestRowMismatchNode.value)) {
346
340
  largestRowMismatchNode = {
347
341
  nodeId: node.id,
@@ -385,14 +379,27 @@ function getExplainDriverLegend(plan) {
385
379
  "editor.visualExplain.mysqlEstimateLegend2"
386
380
  ];
387
381
  case "sqlite":
388
- return [
389
- "editor.visualExplain.sqliteLegend1",
390
- "editor.visualExplain.sqliteLegend2"
391
- ];
382
+ return ["editor.visualExplain.sqliteLegend1", "editor.visualExplain.sqliteLegend2"];
392
383
  default:
393
384
  return [];
394
385
  }
395
386
  }
387
+ function getExplainTableRow(node, hasAnalyzeData, nodeMetrics, fallbackIndex = 1) {
388
+ return {
389
+ index: nodeMetrics?.index ?? fallbackIndex,
390
+ depth: nodeMetrics?.depth ?? 0,
391
+ nodeType: node.node_type,
392
+ relation: node.relation ?? null,
393
+ startupCost: node.startup_cost ?? null,
394
+ totalCost: node.total_cost ?? null,
395
+ planRows: node.plan_rows ?? null,
396
+ actualRows: hasAnalyzeData ? node.actual_rows ?? null : null,
397
+ actualLoops: hasAnalyzeData ? node.actual_loops ?? null : null,
398
+ exclusiveTimeMs: hasAnalyzeData ? nodeMetrics?.exclusiveTimeMs ?? null : null,
399
+ estimateGap: getRowEstimateGap(node),
400
+ filter: node.filter ?? null
401
+ };
402
+ }
396
403
  var KIND_ICONS = {
397
404
  hotspot: Flame,
398
405
  "over-estimate": TrendingUp,
@@ -461,9 +468,7 @@ function ExplainDiagnosticChips({
461
468
  );
462
469
  }) });
463
470
  }
464
- function ExplainDiagnosticList({
465
- diagnostics
466
- }) {
471
+ function ExplainDiagnosticList({ diagnostics }) {
467
472
  const { t } = useTranslation();
468
473
  if (diagnostics.length === 0) {
469
474
  return null;
@@ -475,7 +480,10 @@ function ExplainDiagnosticList({
475
480
  Icon,
476
481
  {
477
482
  size: 13,
478
- className: clsx("mt-0.5 shrink-0", severityTextClass(diagnostic.severity))
483
+ className: clsx(
484
+ "mt-0.5 shrink-0",
485
+ severityTextClass(diagnostic.severity)
486
+ )
479
487
  }
480
488
  ),
481
489
  /* @__PURE__ */ jsxs("div", { className: "min-w-0", children: [
@@ -497,127 +505,125 @@ function ExplainDiagnosticList({
497
505
  ] }, diagnostic.kind);
498
506
  }) });
499
507
  }
500
- var ExplainPlanNodeComponent = memo(
501
- ({ data }) => {
502
- const { t } = useTranslation();
503
- const {
504
- node,
505
- metrics,
506
- maxExclusiveCost,
507
- maxExclusiveTimeMs,
508
- diagnostics,
509
- hasAnalyzeData,
510
- isSelected
511
- } = data;
512
- const useTimeHeat = hasAnalyzeData && maxExclusiveTimeMs > 0;
513
- const costStyle = useTimeHeat ? getNodeCostStyle(metrics?.exclusiveTimeMs ?? 0, maxExclusiveTimeMs) : getNodeCostStyle(metrics?.exclusiveCost ?? 0, maxExclusiveCost);
514
- const rowRatio = getRowEstimateRatio(node);
515
- const mismatch = rowRatio != null && (rowRatio >= 4 || rowRatio <= 0.25) ? rowRatio >= 1 ? {
516
- value: formatRatio(rowRatio),
517
- label: t("editor.visualExplain.overEstimate")
518
- } : {
519
- value: formatRatio(1 / rowRatio),
520
- label: t("editor.visualExplain.underEstimate")
521
- } : null;
522
- return /* @__PURE__ */ jsxs(
523
- "div",
524
- {
525
- className: clsx(
526
- "bg-elevated border border-strong rounded shadow-xl min-w-[260px] max-w-[300px] overflow-hidden transition-all",
527
- "border-l-4",
528
- costStyle.border,
529
- isSelected && "ring-2 ring-blue-400/70 border-blue-400/70"
530
- ),
531
- children: [
532
- /* @__PURE__ */ jsxs("div", { className: clsx("px-3 py-2 border-b border-default", costStyle.headerBg), children: [
533
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
534
- metrics && /* @__PURE__ */ jsxs("span", { className: "shrink-0 rounded bg-surface-secondary/70 px-1.5 py-0.5 font-mono text-[10px] text-muted", children: [
535
- "#",
536
- metrics.index
537
- ] }),
538
- /* @__PURE__ */ jsx("div", { className: "text-sm font-bold text-primary", children: node.node_type })
508
+ var ExplainPlanNodeComponent = memo(({ data }) => {
509
+ const { t } = useTranslation();
510
+ const {
511
+ node,
512
+ metrics,
513
+ maxExclusiveCost,
514
+ maxExclusiveTimeMs,
515
+ diagnostics,
516
+ hasAnalyzeData,
517
+ isSelected
518
+ } = data;
519
+ const useTimeHeat = hasAnalyzeData && maxExclusiveTimeMs > 0;
520
+ const costStyle = useTimeHeat ? getNodeCostStyle(metrics?.exclusiveTimeMs ?? 0, maxExclusiveTimeMs) : getNodeCostStyle(metrics?.exclusiveCost ?? 0, maxExclusiveCost);
521
+ const rowRatio = getRowEstimateRatio(node);
522
+ const mismatch = rowRatio != null && (rowRatio >= 4 || rowRatio <= 0.25) ? rowRatio >= 1 ? {
523
+ value: formatRatio(rowRatio),
524
+ label: t("editor.visualExplain.overEstimate")
525
+ } : {
526
+ value: formatRatio(1 / rowRatio),
527
+ label: t("editor.visualExplain.underEstimate")
528
+ } : null;
529
+ return /* @__PURE__ */ jsxs(
530
+ "div",
531
+ {
532
+ className: clsx(
533
+ "bg-elevated border border-strong rounded shadow-xl min-w-[260px] max-w-[300px] overflow-hidden transition-all",
534
+ "border-l-4",
535
+ costStyle.border,
536
+ isSelected && "ring-2 ring-blue-400/70 border-blue-400/70"
537
+ ),
538
+ children: [
539
+ /* @__PURE__ */ jsxs("div", { className: clsx("px-3 py-2 border-b border-default", costStyle.headerBg), children: [
540
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
541
+ metrics && /* @__PURE__ */ jsxs("span", { className: "shrink-0 rounded bg-surface-secondary/70 px-1.5 py-0.5 font-mono text-[10px] text-muted", children: [
542
+ "#",
543
+ metrics.index
539
544
  ] }),
540
- node.relation && /* @__PURE__ */ jsxs("div", { className: "text-xs text-muted mt-0.5", children: [
541
- t("editor.visualExplain.relation"),
542
- ": ",
543
- node.relation
544
- ] })
545
+ /* @__PURE__ */ jsx("div", { className: "text-sm font-bold text-primary", children: node.node_type })
545
546
  ] }),
546
- /* @__PURE__ */ jsxs("div", { className: "px-3 py-2 space-y-1", children: [
547
- /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between text-xs", children: [
548
- /* @__PURE__ */ jsx("span", { className: "text-muted", children: t("editor.visualExplain.estRows") }),
549
- /* @__PURE__ */ jsx("span", { className: "text-secondary font-mono", children: node.plan_rows != null ? formatRows(node.plan_rows) : "-" })
550
- ] }),
551
- metrics?.exclusiveCost != null && /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between text-xs", children: [
552
- /* @__PURE__ */ jsx("span", { className: "text-muted", children: t("editor.visualExplain.selfCost") }),
553
- /* @__PURE__ */ jsx("span", { className: "text-secondary font-mono", children: formatCost(metrics.exclusiveCost) })
554
- ] }),
555
- mismatch && /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between text-xs", children: [
556
- /* @__PURE__ */ jsx("span", { className: "text-muted", children: t("editor.visualExplain.largestEstimateGap") }),
557
- /* @__PURE__ */ jsx("span", { className: "text-amber-300 font-mono font-semibold", children: mismatch.value })
558
- ] }),
559
- hasAnalyzeData && node.actual_rows != null && /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between text-xs", children: [
560
- /* @__PURE__ */ jsx("span", { className: "text-muted", children: t("editor.visualExplain.actualRows") }),
561
- /* @__PURE__ */ jsx("span", { className: "text-primary font-mono font-semibold", children: formatRows(node.actual_rows) })
562
- ] }),
563
- hasAnalyzeData && metrics?.exclusiveTimeMs != null && /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between text-xs", children: [
564
- /* @__PURE__ */ jsx("span", { className: "text-muted", children: t("editor.visualExplain.selfTime") }),
565
- /* @__PURE__ */ jsxs("span", { className: "text-primary font-mono font-semibold", children: [
566
- formatTime(metrics.exclusiveTimeMs),
567
- metrics.timeShare != null && /* @__PURE__ */ jsxs("span", { className: "ml-1 text-muted font-normal", children: [
568
- "(",
569
- Math.round(metrics.timeShare * 100),
570
- "%)"
571
- ] })
547
+ node.relation && /* @__PURE__ */ jsxs("div", { className: "text-xs text-muted mt-0.5", children: [
548
+ t("editor.visualExplain.relation"),
549
+ ": ",
550
+ node.relation
551
+ ] })
552
+ ] }),
553
+ /* @__PURE__ */ jsxs("div", { className: "px-3 py-2 space-y-1", children: [
554
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between text-xs", children: [
555
+ /* @__PURE__ */ jsx("span", { className: "text-muted", children: t("editor.visualExplain.estRows") }),
556
+ /* @__PURE__ */ jsx("span", { className: "text-secondary font-mono", children: node.plan_rows != null ? formatRows(node.plan_rows) : "-" })
557
+ ] }),
558
+ metrics?.exclusiveCost != null && /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between text-xs", children: [
559
+ /* @__PURE__ */ jsx("span", { className: "text-muted", children: t("editor.visualExplain.selfCost") }),
560
+ /* @__PURE__ */ jsx("span", { className: "text-secondary font-mono", children: formatCost(metrics.exclusiveCost) })
561
+ ] }),
562
+ mismatch && /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between text-xs", children: [
563
+ /* @__PURE__ */ jsx("span", { className: "text-muted", children: t("editor.visualExplain.largestEstimateGap") }),
564
+ /* @__PURE__ */ jsx("span", { className: "text-amber-300 font-mono font-semibold", children: mismatch.value })
565
+ ] }),
566
+ hasAnalyzeData && node.actual_rows != null && /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between text-xs", children: [
567
+ /* @__PURE__ */ jsx("span", { className: "text-muted", children: t("editor.visualExplain.actualRows") }),
568
+ /* @__PURE__ */ jsx("span", { className: "text-primary font-mono font-semibold", children: formatRows(node.actual_rows) })
569
+ ] }),
570
+ hasAnalyzeData && metrics?.exclusiveTimeMs != null && /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between text-xs", children: [
571
+ /* @__PURE__ */ jsx("span", { className: "text-muted", children: t("editor.visualExplain.selfTime") }),
572
+ /* @__PURE__ */ jsxs("span", { className: "text-primary font-mono font-semibold", children: [
573
+ formatTime(metrics.exclusiveTimeMs),
574
+ metrics.timeShare != null && /* @__PURE__ */ jsxs("span", { className: "ml-1 text-muted font-normal", children: [
575
+ "(",
576
+ Math.round(metrics.timeShare * 100),
577
+ "%)"
572
578
  ] })
573
- ] }),
574
- hasAnalyzeData && metrics?.inclusiveTimeMs != null && /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between text-xs", children: [
575
- /* @__PURE__ */ jsx("span", { className: "text-muted", children: t("editor.visualExplain.totalTime") }),
576
- /* @__PURE__ */ jsx("span", { className: "text-secondary font-mono", children: formatTime(metrics.inclusiveTimeMs) })
577
- ] }),
578
- hasAnalyzeData && node.actual_loops != null && node.actual_loops > 1 && /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between text-xs", children: [
579
- /* @__PURE__ */ jsx("span", { className: "text-muted", children: t("editor.visualExplain.loops") }),
580
- /* @__PURE__ */ jsx("span", { className: "text-secondary font-mono", children: node.actual_loops })
581
- ] }),
582
- diagnostics.length > 0 && /* @__PURE__ */ jsx(
583
- ExplainDiagnosticChips,
584
- {
585
- diagnostics,
586
- className: "border-t border-default/50 pt-1.5"
587
- }
588
- ),
589
- node.filter && /* @__PURE__ */ jsxs("div", { className: "text-[10px] text-muted mt-1 font-mono truncate border-t border-default/50 pt-1", children: [
590
- t("editor.visualExplain.filter"),
591
- ": ",
592
- node.filter
593
- ] }),
594
- node.index_condition && /* @__PURE__ */ jsxs("div", { className: "text-[10px] text-muted font-mono truncate", children: [
595
- t("editor.visualExplain.indexCondition"),
596
- ": ",
597
- node.index_condition
598
579
  ] })
599
580
  ] }),
600
- /* @__PURE__ */ jsx(
601
- Handle,
581
+ hasAnalyzeData && metrics?.inclusiveTimeMs != null && /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between text-xs", children: [
582
+ /* @__PURE__ */ jsx("span", { className: "text-muted", children: t("editor.visualExplain.totalTime") }),
583
+ /* @__PURE__ */ jsx("span", { className: "text-secondary font-mono", children: formatTime(metrics.inclusiveTimeMs) })
584
+ ] }),
585
+ hasAnalyzeData && node.actual_loops != null && node.actual_loops > 1 && /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between text-xs", children: [
586
+ /* @__PURE__ */ jsx("span", { className: "text-muted", children: t("editor.visualExplain.loops") }),
587
+ /* @__PURE__ */ jsx("span", { className: "text-secondary font-mono", children: node.actual_loops })
588
+ ] }),
589
+ diagnostics.length > 0 && /* @__PURE__ */ jsx(
590
+ ExplainDiagnosticChips,
602
591
  {
603
- type: "target",
604
- position: Position.Top,
605
- className: "!w-2 !h-2 !bg-indigo-500 !border-strong"
592
+ diagnostics,
593
+ className: "border-t border-default/50 pt-1.5"
606
594
  }
607
595
  ),
608
- /* @__PURE__ */ jsx(
609
- Handle,
610
- {
611
- type: "source",
612
- position: Position.Bottom,
613
- className: "!w-2 !h-2 !bg-indigo-500 !border-strong"
614
- }
615
- )
616
- ]
617
- }
618
- );
619
- }
620
- );
596
+ node.filter && /* @__PURE__ */ jsxs("div", { className: "text-[10px] text-muted mt-1 font-mono truncate border-t border-default/50 pt-1", children: [
597
+ t("editor.visualExplain.filter"),
598
+ ": ",
599
+ node.filter
600
+ ] }),
601
+ node.index_condition && /* @__PURE__ */ jsxs("div", { className: "text-[10px] text-muted font-mono truncate", children: [
602
+ t("editor.visualExplain.indexCondition"),
603
+ ": ",
604
+ node.index_condition
605
+ ] })
606
+ ] }),
607
+ /* @__PURE__ */ jsx(
608
+ Handle,
609
+ {
610
+ type: "target",
611
+ position: Position.Top,
612
+ className: "!w-2 !h-2 !bg-indigo-500 !border-strong"
613
+ }
614
+ ),
615
+ /* @__PURE__ */ jsx(
616
+ Handle,
617
+ {
618
+ type: "source",
619
+ position: Position.Bottom,
620
+ className: "!w-2 !h-2 !bg-indigo-500 !border-strong"
621
+ }
622
+ )
623
+ ]
624
+ }
625
+ );
626
+ });
621
627
  var nodeTypes = {
622
628
  explainPlan: ExplainPlanNodeComponent
623
629
  };
@@ -659,13 +665,7 @@ function ExplainGraphInner({
659
665
  children: [
660
666
  /* @__PURE__ */ jsx(Background, {}),
661
667
  /* @__PURE__ */ jsx(Controls, {}),
662
- nodes.length > 10 && /* @__PURE__ */ jsx(
663
- MiniMap,
664
- {
665
- nodeStrokeWidth: 3,
666
- className: "!bg-base !border-default"
667
- }
668
- )
668
+ nodes.length > 10 && /* @__PURE__ */ jsx(MiniMap, { nodeStrokeWidth: 3, className: "!bg-base !border-default" })
669
669
  ]
670
670
  }
671
671
  );
@@ -688,96 +688,340 @@ var ExplainGraph = ({
688
688
  }
689
689
  ) }) });
690
690
  };
691
- function ExplainNodeDetails({
692
- node,
693
- hasAnalyzeData,
694
- metrics = null,
695
- diagnostics = []
696
- }) {
697
- const { t } = useTranslation();
698
- if (!node) {
699
- return /* @__PURE__ */ jsx("div", { className: "p-4 text-xs text-muted", children: t("editor.visualExplain.selectNode") });
691
+
692
+ // src/tableColumns.ts
693
+ var EXPLAIN_TABLE_COLUMNS = [
694
+ "operation",
695
+ "relation",
696
+ "cost",
697
+ "estRows",
698
+ "actualRows",
699
+ "loops",
700
+ "selfTime",
701
+ "estimateGap",
702
+ "filter"
703
+ ];
704
+ var EXPLAIN_TABLE_ANALYZE_COLUMNS = /* @__PURE__ */ new Set([
705
+ "actualRows",
706
+ "loops"
707
+ ]);
708
+ function isExplainTableColumnId(value) {
709
+ return typeof value === "string" && EXPLAIN_TABLE_COLUMNS.includes(value);
710
+ }
711
+ function normalizeExplainTableColumnOrder(saved) {
712
+ const order = [];
713
+ if (Array.isArray(saved)) {
714
+ for (const id of saved) {
715
+ if (isExplainTableColumnId(id) && !order.includes(id)) order.push(id);
716
+ }
700
717
  }
718
+ EXPLAIN_TABLE_COLUMNS.forEach((id, defaultIndex) => {
719
+ if (order.includes(id)) return;
720
+ const previous = EXPLAIN_TABLE_COLUMNS.slice(0, defaultIndex).reverse();
721
+ const anchor = previous.find((candidate) => order.includes(candidate));
722
+ order.splice(anchor ? order.indexOf(anchor) + 1 : 0, 0, id);
723
+ });
724
+ return order;
725
+ }
726
+ function moveExplainTableColumn(order, column, target) {
727
+ const from = order.indexOf(column);
728
+ const to = order.indexOf(target);
729
+ if (from === -1 || to === -1 || from === to) return order;
730
+ const next = order.filter((id) => id !== column);
731
+ next.splice(to, 0, column);
732
+ return next;
733
+ }
734
+ function shiftExplainTableColumn(order, column, direction, visible = order) {
735
+ const shown = order.filter((id) => visible.includes(id));
736
+ const at = shown.indexOf(column);
737
+ const neighbour = at === -1 ? void 0 : shown[at + direction];
738
+ return neighbour ? moveExplainTableColumn(order, column, neighbour) : order;
739
+ }
740
+ function isDefaultExplainTableColumnOrder(order) {
741
+ return order.length === EXPLAIN_TABLE_COLUMNS.length && order.every((id, index) => id === EXPLAIN_TABLE_COLUMNS[index]);
742
+ }
743
+
744
+ // src/nodeDetails.ts
745
+ var DEFAULT_LABELS = {
746
+ "editor.visualExplain.nodeType": "Operation",
747
+ "editor.visualExplain.relation": "Table",
748
+ "editor.visualExplain.cost": "Cost",
749
+ "editor.visualExplain.selfCost": "Self Cost",
750
+ "editor.visualExplain.estRows": "Est. Rows",
751
+ "editor.visualExplain.filter": "Filter",
752
+ "editor.visualExplain.indexCondition": "Index Cond.",
753
+ "editor.visualExplain.joinType": "Join Type",
754
+ "editor.visualExplain.hashCondition": "Hash Cond.",
755
+ "editor.visualExplain.actualRows": "Actual Rows",
756
+ "editor.visualExplain.rowsAllLoops": "Rows (All Loops)",
757
+ "editor.visualExplain.selfTime": "Self Time",
758
+ "editor.visualExplain.totalTime": "Total Time",
759
+ "editor.visualExplain.timePerLoop": "Time per Loop",
760
+ "editor.visualExplain.loops": "Loops",
761
+ "editor.visualExplain.buffersHit": "Buffers Hit",
762
+ "editor.visualExplain.buffersRead": "Buffers Read",
763
+ "editor.visualExplain.general": "General",
764
+ "editor.visualExplain.analyzeData": "Analyze Data",
765
+ "editor.visualExplain.extraDetails": "Extra",
766
+ "editor.visualExplain.diagnostics.title": "Diagnostics"
767
+ };
768
+ function getExplainNodeDetailSections(node, options = {}) {
769
+ const { hasAnalyzeData = false, metrics = null, diagnostics = [], t } = options;
770
+ const tr = (key, fallback) => {
771
+ if (t) {
772
+ return t(key, fallback ?? DEFAULT_LABELS[key] ?? key);
773
+ }
774
+ return fallback ?? DEFAULT_LABELS[key] ?? key;
775
+ };
701
776
  const generalEntries = [
702
- [t("editor.visualExplain.nodeType"), node.node_type],
703
- ...node.relation ? [[t("editor.visualExplain.relation"), node.relation]] : [],
777
+ { label: tr("editor.visualExplain.nodeType", "Operation"), value: node.node_type },
778
+ ...node.relation ? [{ label: tr("editor.visualExplain.relation", "Table"), value: node.relation }] : [],
704
779
  ...node.startup_cost != null && node.total_cost != null ? [
705
- [
706
- t("editor.visualExplain.cost"),
707
- `${formatCost(node.startup_cost)} - ${formatCost(node.total_cost)}`
708
- ]
709
- ] : node.total_cost != null ? [[t("editor.visualExplain.cost"), formatCost(node.total_cost)]] : [],
780
+ {
781
+ label: tr("editor.visualExplain.cost", "Cost"),
782
+ value: `${formatCost(node.startup_cost)} - ${formatCost(node.total_cost)}`
783
+ }
784
+ ] : node.total_cost != null ? [
785
+ {
786
+ label: tr("editor.visualExplain.cost", "Cost"),
787
+ value: formatCost(node.total_cost)
788
+ }
789
+ ] : [],
710
790
  ...metrics?.exclusiveCost != null ? [
711
- [
712
- t("editor.visualExplain.selfCost"),
713
- formatCost(metrics.exclusiveCost)
714
- ]
791
+ {
792
+ label: tr("editor.visualExplain.selfCost", "Self Cost"),
793
+ value: formatCost(metrics.exclusiveCost)
794
+ }
715
795
  ] : [],
716
- ...node.plan_rows != null ? [[t("editor.visualExplain.estRows"), formatRows(node.plan_rows)]] : [],
717
- ...node.filter ? [[t("editor.visualExplain.filter"), node.filter]] : [],
718
- ...node.index_condition ? [[t("editor.visualExplain.indexCondition"), node.index_condition]] : [],
719
- ...node.join_type ? [[t("editor.visualExplain.joinType"), node.join_type]] : [],
720
- ...node.hash_condition ? [[t("editor.visualExplain.hashCondition"), node.hash_condition]] : []
796
+ ...node.plan_rows != null ? [
797
+ {
798
+ label: tr("editor.visualExplain.estRows", "Est. Rows"),
799
+ value: formatRows(node.plan_rows)
800
+ }
801
+ ] : [],
802
+ ...node.filter ? [{ label: tr("editor.visualExplain.filter", "Filter"), value: node.filter }] : [],
803
+ ...node.index_condition ? [
804
+ {
805
+ label: tr("editor.visualExplain.indexCondition", "Index Cond."),
806
+ value: node.index_condition
807
+ }
808
+ ] : [],
809
+ ...node.join_type ? [{ label: tr("editor.visualExplain.joinType", "Join Type"), value: node.join_type }] : [],
810
+ ...node.hash_condition ? [
811
+ {
812
+ label: tr("editor.visualExplain.hashCondition", "Hash Cond."),
813
+ value: node.hash_condition
814
+ }
815
+ ] : []
721
816
  ];
722
817
  const analyzeEntries = hasAnalyzeData ? [
723
- ...node.actual_rows != null ? [[t("editor.visualExplain.actualRows"), formatRows(node.actual_rows)]] : [],
818
+ ...node.actual_rows != null ? [
819
+ {
820
+ label: tr("editor.visualExplain.actualRows", "Actual Rows"),
821
+ value: formatRows(node.actual_rows)
822
+ }
823
+ ] : [],
724
824
  ...metrics?.totalRows != null && node.actual_loops != null && node.actual_loops > 1 ? [
725
- [
726
- t("editor.visualExplain.rowsAllLoops"),
727
- formatRows(metrics.totalRows)
728
- ]
825
+ {
826
+ label: tr("editor.visualExplain.rowsAllLoops", "Rows (All Loops)"),
827
+ value: formatRows(metrics.totalRows)
828
+ }
729
829
  ] : [],
730
830
  ...metrics?.exclusiveTimeMs != null ? [
731
- [
732
- t("editor.visualExplain.selfTime"),
733
- metrics.timeShare != null ? `${formatTime(metrics.exclusiveTimeMs)} (${Math.round(metrics.timeShare * 100)}%)` : formatTime(metrics.exclusiveTimeMs)
734
- ]
831
+ {
832
+ label: tr("editor.visualExplain.selfTime", "Self Time"),
833
+ value: metrics.timeShare != null ? `${formatTime(metrics.exclusiveTimeMs)} (${Math.round(metrics.timeShare * 100)}%)` : formatTime(metrics.exclusiveTimeMs)
834
+ }
735
835
  ] : [],
736
836
  ...metrics?.inclusiveTimeMs != null ? [
737
- [
738
- t("editor.visualExplain.totalTime"),
739
- formatTime(metrics.inclusiveTimeMs)
740
- ]
837
+ {
838
+ label: tr("editor.visualExplain.totalTime", "Total Time"),
839
+ value: formatTime(metrics.inclusiveTimeMs)
840
+ }
741
841
  ] : [],
742
842
  ...node.actual_time_ms != null ? [
743
- [
744
- t("editor.visualExplain.timePerLoop"),
745
- formatTime(node.actual_time_ms)
746
- ]
843
+ {
844
+ label: tr("editor.visualExplain.timePerLoop", "Time per Loop"),
845
+ value: formatTime(node.actual_time_ms)
846
+ }
747
847
  ] : [],
748
- ...node.actual_loops != null ? [[t("editor.visualExplain.loops"), String(node.actual_loops)]] : [],
749
- ...node.buffers_hit != null ? [[t("editor.visualExplain.buffersHit"), String(node.buffers_hit)]] : [],
750
- ...node.buffers_read != null ? [[t("editor.visualExplain.buffersRead"), String(node.buffers_read)]] : []
751
- ] : [];
752
- const extraEntries = Object.entries(node.extra).map(
753
- ([key, value]) => [key, typeof value === "string" ? value : JSON.stringify(value)]
754
- );
755
- return /* @__PURE__ */ jsxs("div", { className: "text-xs", children: [
756
- diagnostics.length > 0 && /* @__PURE__ */ jsxs("div", { className: "border-b border-default/60", children: [
757
- /* @__PURE__ */ jsx("div", { className: "bg-base/60 px-4 py-3 text-[11px] font-semibold uppercase tracking-wide text-muted", children: t("editor.visualExplain.diagnostics.title") }),
758
- /* @__PURE__ */ jsx(ExplainDiagnosticList, { diagnostics })
759
- ] }),
760
- /* @__PURE__ */ jsx(
761
- DetailSection,
848
+ ...node.actual_loops != null ? [
762
849
  {
763
- title: t("editor.visualExplain.general"),
764
- entries: generalEntries
850
+ label: tr("editor.visualExplain.loops", "Loops"),
851
+ value: String(node.actual_loops)
765
852
  }
766
- ),
767
- analyzeEntries.length > 0 && /* @__PURE__ */ jsx(
768
- DetailSection,
853
+ ] : [],
854
+ ...node.buffers_hit != null ? [
769
855
  {
770
- title: t("editor.visualExplain.analyzeData"),
771
- entries: analyzeEntries
856
+ label: tr("editor.visualExplain.buffersHit", "Buffers Hit"),
857
+ value: String(node.buffers_hit)
772
858
  }
773
- ),
774
- extraEntries.length > 0 && /* @__PURE__ */ jsx(
775
- DetailSection,
859
+ ] : [],
860
+ ...node.buffers_read != null ? [
776
861
  {
777
- title: t("editor.visualExplain.extraDetails"),
778
- entries: extraEntries
862
+ label: tr("editor.visualExplain.buffersRead", "Buffers Read"),
863
+ value: String(node.buffers_read)
864
+ }
865
+ ] : []
866
+ ] : [];
867
+ const extraEntries = Object.entries(node.extra ?? {}).map(
868
+ ([key, value]) => ({
869
+ label: key,
870
+ value: typeof value === "string" ? value : JSON.stringify(value)
871
+ })
872
+ );
873
+ const sections = [];
874
+ if (generalEntries.length > 0) {
875
+ sections.push({
876
+ title: tr("editor.visualExplain.general", "General"),
877
+ titleKey: "editor.visualExplain.general",
878
+ entries: generalEntries
879
+ });
880
+ }
881
+ if (analyzeEntries.length > 0) {
882
+ sections.push({
883
+ title: tr("editor.visualExplain.analyzeData", "Analyze Data"),
884
+ titleKey: "editor.visualExplain.analyzeData",
885
+ entries: analyzeEntries
886
+ });
887
+ }
888
+ if (extraEntries.length > 0) {
889
+ sections.push({
890
+ title: tr("editor.visualExplain.extraDetails", "Extra"),
891
+ titleKey: "editor.visualExplain.extraDetails",
892
+ entries: extraEntries
893
+ });
894
+ }
895
+ if (diagnostics && diagnostics.length > 0) {
896
+ const diagEntries = diagnostics.map((d) => ({
897
+ label: tr(d.labelKey, d.kind),
898
+ value: d.value ? `${tr(d.descriptionKey, d.kind)} (${d.value})` : tr(d.descriptionKey, d.kind)
899
+ }));
900
+ sections.push({
901
+ title: tr("editor.visualExplain.diagnostics.title", "Diagnostics"),
902
+ titleKey: "editor.visualExplain.diagnostics.title",
903
+ entries: diagEntries
904
+ });
905
+ }
906
+ return sections;
907
+ }
908
+ function formatExplainNodeDetails(node, options = {}) {
909
+ const sections = getExplainNodeDetailSections(node, options);
910
+ const parts = [];
911
+ for (const section of sections) {
912
+ if (section.entries.length === 0) continue;
913
+ const lines = section.entries.map((entry) => `${entry.label}: ${entry.value}`);
914
+ if (options.includeSectionHeaders || section.titleKey !== "editor.visualExplain.general") {
915
+ parts.push(`${section.title}:
916
+ ${lines.join("\n")}`);
917
+ } else {
918
+ parts.push(lines.join("\n"));
919
+ }
920
+ }
921
+ return parts.join("\n\n");
922
+ }
923
+ function ExplainNodeDetails({
924
+ node,
925
+ hasAnalyzeData,
926
+ metrics = null,
927
+ diagnostics = [],
928
+ onCopyDetails,
929
+ className
930
+ }) {
931
+ const { t } = useTranslation();
932
+ const translate = useCallback(
933
+ (key, fallback) => {
934
+ return t(key, fallback ? { defaultValue: fallback } : void 0) || fallback || key;
935
+ },
936
+ [t]
937
+ );
938
+ const [copiedNodeId, setCopiedNodeId] = useState(null);
939
+ const copied = node !== null && copiedNodeId === node.id;
940
+ const [isCopying, setIsCopying] = useState(false);
941
+ const timerRef = useRef(null);
942
+ useEffect(() => {
943
+ return () => {
944
+ if (timerRef.current) {
945
+ clearTimeout(timerRef.current);
946
+ }
947
+ };
948
+ }, []);
949
+ const handleCopy = useCallback(async () => {
950
+ if (!node || isCopying) return;
951
+ const text = formatExplainNodeDetails(node, {
952
+ hasAnalyzeData,
953
+ metrics,
954
+ diagnostics,
955
+ t: translate
956
+ });
957
+ setIsCopying(true);
958
+ try {
959
+ let success = false;
960
+ if (onCopyDetails) {
961
+ const result = await onCopyDetails(text);
962
+ if (result !== false) {
963
+ success = true;
964
+ }
965
+ } else if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
966
+ await navigator.clipboard.writeText(text);
967
+ success = true;
779
968
  }
780
- )
969
+ if (success) {
970
+ if (timerRef.current) {
971
+ clearTimeout(timerRef.current);
972
+ }
973
+ setCopiedNodeId(node.id);
974
+ timerRef.current = setTimeout(() => {
975
+ setCopiedNodeId(null);
976
+ }, 2e3);
977
+ }
978
+ } catch (err) {
979
+ console.error("Failed to copy node details:", err);
980
+ } finally {
981
+ setIsCopying(false);
982
+ }
983
+ }, [node, isCopying, hasAnalyzeData, metrics, diagnostics, translate, onCopyDetails]);
984
+ const sections = node ? getExplainNodeDetailSections(node, {
985
+ hasAnalyzeData,
986
+ metrics,
987
+ t: translate
988
+ }) : [];
989
+ return /* @__PURE__ */ jsxs("div", { className: clsx("text-xs flex flex-col", className), children: [
990
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between px-4 py-2 border-b border-default/60 bg-base/60 sticky top-0 z-10", children: [
991
+ /* @__PURE__ */ jsx("span", { className: "text-[11px] font-semibold uppercase tracking-wide text-muted", children: t("editor.visualExplain.nodeDetails", "Node Details") }),
992
+ /* @__PURE__ */ jsx(
993
+ "button",
994
+ {
995
+ type: "button",
996
+ onClick: handleCopy,
997
+ disabled: !node || isCopying,
998
+ className: "flex items-center gap-1.5 px-2 py-0.5 text-xs rounded border border-default bg-surface hover:bg-surface-secondary text-primary disabled:opacity-40 disabled:cursor-not-allowed transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-focus",
999
+ title: node ? t("editor.visualExplain.copyDetails", "Copy Details") : t("editor.visualExplain.selectNode", "Select a node to view details"),
1000
+ "aria-label": t("editor.visualExplain.copyDetails", "Copy Details"),
1001
+ children: copied ? /* @__PURE__ */ jsxs(Fragment, { children: [
1002
+ /* @__PURE__ */ jsx(Check, { size: 12, className: "text-accent-success" }),
1003
+ /* @__PURE__ */ jsx("span", { className: "text-accent-success text-[11px]", children: t("editor.visualExplain.copied", "Copied") })
1004
+ ] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
1005
+ /* @__PURE__ */ jsx(Copy, { size: 12, className: "text-secondary" }),
1006
+ /* @__PURE__ */ jsx("span", { className: "text-secondary text-[11px]", children: t("editor.visualExplain.copyDetails", "Copy Details") })
1007
+ ] })
1008
+ }
1009
+ )
1010
+ ] }),
1011
+ !node ? /* @__PURE__ */ jsx("div", { className: "p-4 text-xs text-muted", children: t("editor.visualExplain.selectNode", "Select a node to view details") }) : /* @__PURE__ */ jsxs("div", { children: [
1012
+ diagnostics.length > 0 && /* @__PURE__ */ jsxs("div", { className: "border-b border-default/60", children: [
1013
+ /* @__PURE__ */ jsx("div", { className: "bg-base/60 px-4 py-3 text-[11px] font-semibold uppercase tracking-wide text-muted", children: t("editor.visualExplain.diagnostics.title", "Diagnostics") }),
1014
+ /* @__PURE__ */ jsx(ExplainDiagnosticList, { diagnostics })
1015
+ ] }),
1016
+ sections.map((section) => /* @__PURE__ */ jsx(
1017
+ DetailSection,
1018
+ {
1019
+ title: section.title,
1020
+ entries: section.entries
1021
+ },
1022
+ section.title
1023
+ ))
1024
+ ] })
781
1025
  ] });
782
1026
  }
783
1027
  function DetailSection({ title, entries }) {
@@ -786,20 +1030,146 @@ function DetailSection({ title, entries }) {
786
1030
  }
787
1031
  return /* @__PURE__ */ jsxs("div", { className: "border-b border-default/60 last:border-b-0", children: [
788
1032
  /* @__PURE__ */ jsx("div", { className: "px-4 py-3 text-[11px] uppercase tracking-wide text-muted font-semibold bg-base/60", children: title }),
789
- /* @__PURE__ */ jsx("div", { className: "divide-y divide-default/40", children: entries.map(([label, value]) => /* @__PURE__ */ jsxs("div", { className: "px-4 py-2.5", children: [
1033
+ /* @__PURE__ */ jsx("div", { className: "divide-y divide-default/40", children: entries.map(({ label, value }) => /* @__PURE__ */ jsxs("div", { className: "px-4 py-2.5", children: [
790
1034
  /* @__PURE__ */ jsx("div", { className: "text-[11px] text-muted mb-1", children: label }),
791
1035
  /* @__PURE__ */ jsx("div", { className: "text-secondary break-words font-mono leading-relaxed", children: value })
792
1036
  ] }, label)) })
793
1037
  ] });
794
1038
  }
1039
+
1040
+ // src/components/columnDrag.ts
1041
+ function startColumnDrag(startX, startY, options) {
1042
+ const { threshold = 4, onDragStart, onDragMove, onDrop, onEnd } = options;
1043
+ let started = false;
1044
+ let overlay = null;
1045
+ const handleMouseMove = (event) => {
1046
+ if (!started) {
1047
+ if (Math.abs(event.clientX - startX) < threshold && Math.abs(event.clientY - startY) < threshold) {
1048
+ return;
1049
+ }
1050
+ started = true;
1051
+ overlay = document.createElement("div");
1052
+ overlay.style.cssText = "position:fixed;inset:0;z-index:9999;cursor:grabbing;user-select:none";
1053
+ document.body.appendChild(overlay);
1054
+ onDragStart();
1055
+ }
1056
+ onDragMove(event.clientX, event.clientY);
1057
+ };
1058
+ const handleMouseUp = (event) => {
1059
+ document.removeEventListener("mousemove", handleMouseMove);
1060
+ document.removeEventListener("mouseup", handleMouseUp);
1061
+ if (started) {
1062
+ overlay?.remove();
1063
+ const suppressClick = (clickEvent) => {
1064
+ clickEvent.stopPropagation();
1065
+ clickEvent.preventDefault();
1066
+ window.removeEventListener("click", suppressClick, true);
1067
+ };
1068
+ window.addEventListener("click", suppressClick, true);
1069
+ setTimeout(() => window.removeEventListener("click", suppressClick, true), 0);
1070
+ onDrop(event.clientX, event.clientY);
1071
+ }
1072
+ onEnd();
1073
+ };
1074
+ document.addEventListener("mousemove", handleMouseMove);
1075
+ document.addEventListener("mouseup", handleMouseUp);
1076
+ }
1077
+ var COLUMN_LABEL_KEYS = {
1078
+ operation: "editor.visualExplain.nodeType",
1079
+ relation: "editor.visualExplain.relation",
1080
+ cost: "editor.visualExplain.cost",
1081
+ estRows: "editor.visualExplain.estRows",
1082
+ actualRows: "editor.visualExplain.actualRows",
1083
+ loops: "editor.visualExplain.loops",
1084
+ selfTime: "editor.visualExplain.selfTime",
1085
+ estimateGap: "editor.visualExplain.largestEstimateGap",
1086
+ filter: "editor.visualExplain.filter"
1087
+ };
1088
+ var LEFT_ALIGNED_COLUMNS = /* @__PURE__ */ new Set([
1089
+ "operation",
1090
+ "relation",
1091
+ "filter"
1092
+ ]);
795
1093
  function ExplainTableView({
796
1094
  plan,
797
1095
  metrics,
798
1096
  diagnostics,
799
1097
  selectedId,
800
- onSelect
1098
+ onSelect,
1099
+ onExportCsv,
1100
+ isExportingCsv,
1101
+ columnOrder,
1102
+ onColumnOrderChange,
1103
+ onCopyDetails
801
1104
  }) {
802
1105
  const { t } = useTranslation();
1106
+ const [ownColumnOrder, setOwnColumnOrder] = useState(EXPLAIN_TABLE_COLUMNS);
1107
+ const order = useMemo(
1108
+ () => normalizeExplainTableColumnOrder(columnOrder ?? ownColumnOrder),
1109
+ [columnOrder, ownColumnOrder]
1110
+ );
1111
+ const visibleColumns = useMemo(
1112
+ () => order.filter((id) => plan.has_analyze_data || !EXPLAIN_TABLE_ANALYZE_COLUMNS.has(id)),
1113
+ [order, plan.has_analyze_data]
1114
+ );
1115
+ const isDefaultOrder = isDefaultExplainTableColumnOrder(order);
1116
+ const [draggedColumn, setDraggedColumn] = useState(null);
1117
+ const [dropTarget, setDropTarget] = useState(null);
1118
+ const changeOrder = useCallback(
1119
+ (next) => {
1120
+ if (next === order) return;
1121
+ setOwnColumnOrder(next);
1122
+ onColumnOrderChange?.(next);
1123
+ },
1124
+ [order, onColumnOrderChange]
1125
+ );
1126
+ const headerRowRef = useRef(null);
1127
+ const columnAt = useCallback((x) => {
1128
+ const headers = headerRowRef.current?.querySelectorAll("th[data-column]");
1129
+ for (const header of headers ?? []) {
1130
+ const rect = header.getBoundingClientRect();
1131
+ if (x >= rect.left && x <= rect.right) {
1132
+ return header.dataset.column;
1133
+ }
1134
+ }
1135
+ return null;
1136
+ }, []);
1137
+ const handleHeaderMouseDown = useCallback(
1138
+ (event, column) => {
1139
+ if (event.button !== 0) return;
1140
+ let target = null;
1141
+ startColumnDrag(event.clientX, event.clientY, {
1142
+ onDragStart: () => setDraggedColumn(column),
1143
+ onDragMove: (x) => {
1144
+ target = columnAt(x);
1145
+ setDropTarget(target);
1146
+ },
1147
+ onDrop: () => {
1148
+ if (target) changeOrder(moveExplainTableColumn(order, column, target));
1149
+ },
1150
+ onEnd: () => {
1151
+ setDraggedColumn(null);
1152
+ setDropTarget(null);
1153
+ }
1154
+ });
1155
+ },
1156
+ [changeOrder, columnAt, order]
1157
+ );
1158
+ const handleHeaderKeyDown = useCallback(
1159
+ (event, column) => {
1160
+ if (!event.altKey || event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
1161
+ event.preventDefault();
1162
+ changeOrder(
1163
+ shiftExplainTableColumn(
1164
+ order,
1165
+ column,
1166
+ event.key === "ArrowLeft" ? -1 : 1,
1167
+ visibleColumns
1168
+ )
1169
+ );
1170
+ },
1171
+ [changeOrder, order, visibleColumns]
1172
+ );
803
1173
  const [expandedIds, setExpandedIds] = useState(
804
1174
  () => collectExpandedIds(plan.root)
805
1175
  );
@@ -816,40 +1186,98 @@ function ExplainTableView({
816
1186
  }, []);
817
1187
  const selectedNode = findExplainNode(plan.root, selectedId);
818
1188
  return /* @__PURE__ */ jsxs("div", { className: "flex h-full", children: [
819
- /* @__PURE__ */ jsx("div", { className: "flex-1 overflow-auto border-r border-default min-w-0", children: /* @__PURE__ */ jsxs("table", { className: "w-full text-xs", children: [
820
- /* @__PURE__ */ jsx("thead", { className: "sticky top-0 z-10 bg-base border-b border-default", children: /* @__PURE__ */ jsxs("tr", { children: [
821
- /* @__PURE__ */ jsx("th", { className: "text-left px-3 py-2 text-muted font-semibold whitespace-nowrap", children: "#" }),
822
- /* @__PURE__ */ jsx("th", { className: "text-left px-3 py-2 text-muted font-semibold whitespace-nowrap", children: t("editor.visualExplain.nodeType") }),
823
- /* @__PURE__ */ jsx("th", { className: "text-left px-3 py-2 text-muted font-semibold whitespace-nowrap", children: t("editor.visualExplain.relation") }),
824
- /* @__PURE__ */ jsx("th", { className: "text-right px-3 py-2 text-muted font-semibold whitespace-nowrap", children: t("editor.visualExplain.cost") }),
825
- /* @__PURE__ */ jsx("th", { className: "text-right px-3 py-2 text-muted font-semibold whitespace-nowrap", children: t("editor.visualExplain.estRows") }),
826
- plan.has_analyze_data && /* @__PURE__ */ jsx("th", { className: "text-right px-3 py-2 text-muted font-semibold whitespace-nowrap", children: t("editor.visualExplain.actualRows") }),
827
- /* @__PURE__ */ jsx("th", { className: "text-right px-3 py-2 text-muted font-semibold whitespace-nowrap", children: t("editor.visualExplain.selfTime") }),
828
- /* @__PURE__ */ jsx("th", { className: "text-right px-3 py-2 text-muted font-semibold whitespace-nowrap", children: t("editor.visualExplain.largestEstimateGap") }),
829
- /* @__PURE__ */ jsx("th", { className: "text-left px-3 py-2 text-muted font-semibold whitespace-nowrap", children: t("editor.visualExplain.filter") })
830
- ] }) }),
831
- /* @__PURE__ */ jsx("tbody", { children: /* @__PURE__ */ jsx(
832
- TreeRows,
833
- {
834
- node: plan.root,
835
- depth: 0,
836
- expandedIds,
837
- selectedId,
838
- onToggle: toggleExpand,
839
- onSelect,
840
- hasAnalyzeData: plan.has_analyze_data,
841
- metrics,
842
- diagnostics
843
- }
844
- ) })
845
- ] }) }),
1189
+ /* @__PURE__ */ jsxs("div", { className: "flex-1 flex flex-col min-w-0 border-r border-default", children: [
1190
+ (onExportCsv || !isDefaultOrder) && /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between px-3 py-1.5 border-b border-default bg-base/50 text-xs shrink-0", children: [
1191
+ /* @__PURE__ */ jsx("span", { className: "text-muted font-medium", children: t("editor.visualExplain.tableTitle", "Plan Tree") }),
1192
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1", children: [
1193
+ !isDefaultOrder && /* @__PURE__ */ jsxs(
1194
+ "button",
1195
+ {
1196
+ type: "button",
1197
+ onClick: () => changeOrder(EXPLAIN_TABLE_COLUMNS),
1198
+ className: "flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium text-secondary hover:text-primary hover:bg-surface-secondary transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus",
1199
+ children: [
1200
+ /* @__PURE__ */ jsx(RotateCcw, { size: 13, "aria-hidden": "true" }),
1201
+ /* @__PURE__ */ jsx("span", { children: t(
1202
+ "editor.visualExplain.resetColumnOrder",
1203
+ "Reset column order"
1204
+ ) })
1205
+ ]
1206
+ }
1207
+ ),
1208
+ onExportCsv && /* @__PURE__ */ jsxs(
1209
+ "button",
1210
+ {
1211
+ type: "button",
1212
+ onClick: onExportCsv,
1213
+ disabled: isExportingCsv,
1214
+ className: "flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium text-secondary hover:text-primary hover:bg-surface-secondary transition-colors disabled:opacity-50 disabled:pointer-events-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus",
1215
+ "aria-label": t("editor.visualExplain.exportCsv", "Export CSV"),
1216
+ title: t("editor.visualExplain.exportCsv", "Export CSV"),
1217
+ children: [
1218
+ /* @__PURE__ */ jsx(Download, { size: 13, "aria-hidden": "true" }),
1219
+ /* @__PURE__ */ jsx("span", { children: isExportingCsv ? t("editor.visualExplain.exportingCsv", "Exporting...") : t("editor.visualExplain.exportCsv", "Export CSV") })
1220
+ ]
1221
+ }
1222
+ )
1223
+ ] })
1224
+ ] }),
1225
+ /* @__PURE__ */ jsx("div", { className: "flex-1 overflow-auto min-h-0", children: /* @__PURE__ */ jsxs("table", { className: "w-full text-xs", children: [
1226
+ /* @__PURE__ */ jsx("thead", { className: "sticky top-0 z-10 bg-base border-b border-default", children: /* @__PURE__ */ jsxs("tr", { ref: headerRowRef, children: [
1227
+ /* @__PURE__ */ jsx("th", { className: "text-left px-3 py-2 text-muted font-semibold whitespace-nowrap", children: "#" }),
1228
+ visibleColumns.map((column) => /* @__PURE__ */ jsx(
1229
+ "th",
1230
+ {
1231
+ "data-column": column,
1232
+ className: clsx(
1233
+ "px-3 py-2 text-muted font-semibold whitespace-nowrap transition-colors",
1234
+ LEFT_ALIGNED_COLUMNS.has(column) ? "text-left" : "text-right",
1235
+ draggedColumn === column && "opacity-50",
1236
+ dropTarget === column && draggedColumn !== column && "bg-accent-primary/15"
1237
+ ),
1238
+ children: /* @__PURE__ */ jsx(
1239
+ "button",
1240
+ {
1241
+ type: "button",
1242
+ onMouseDown: (event) => handleHeaderMouseDown(event, column),
1243
+ onKeyDown: (event) => handleHeaderKeyDown(event, column),
1244
+ title: t(
1245
+ "editor.visualExplain.moveColumnHint",
1246
+ "Drag to reorder, or press Alt+Left / Alt+Right"
1247
+ ),
1248
+ className: "cursor-grab font-semibold rounded focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus",
1249
+ children: t(COLUMN_LABEL_KEYS[column])
1250
+ }
1251
+ )
1252
+ },
1253
+ column
1254
+ ))
1255
+ ] }) }),
1256
+ /* @__PURE__ */ jsx("tbody", { children: /* @__PURE__ */ jsx(
1257
+ TreeRows,
1258
+ {
1259
+ node: plan.root,
1260
+ depth: 0,
1261
+ expandedIds,
1262
+ selectedId,
1263
+ onToggle: toggleExpand,
1264
+ onSelect,
1265
+ hasAnalyzeData: plan.has_analyze_data,
1266
+ metrics,
1267
+ diagnostics,
1268
+ columns: visibleColumns
1269
+ }
1270
+ ) })
1271
+ ] }) })
1272
+ ] }),
846
1273
  /* @__PURE__ */ jsx("div", { className: "w-[320px] shrink-0 overflow-y-auto bg-base/50", children: /* @__PURE__ */ jsx(
847
1274
  ExplainNodeDetails,
848
1275
  {
849
1276
  node: selectedNode,
850
1277
  hasAnalyzeData: plan.has_analyze_data,
851
1278
  metrics: selectedNode ? metrics.byId.get(selectedNode.id) ?? null : null,
852
- diagnostics: selectedNode ? diagnostics.get(selectedNode.id) ?? [] : []
1279
+ diagnostics: selectedNode ? diagnostics.get(selectedNode.id) ?? [] : [],
1280
+ onCopyDetails
853
1281
  }
854
1282
  ) })
855
1283
  ] });
@@ -863,19 +1291,74 @@ function TreeRows({
863
1291
  onSelect,
864
1292
  hasAnalyzeData,
865
1293
  metrics,
866
- diagnostics
1294
+ diagnostics,
1295
+ columns
867
1296
  }) {
868
1297
  const isExpanded = expandedIds.has(node.id);
869
1298
  const hasChildren = node.children.length > 0;
870
1299
  const isSelected = selectedId === node.id;
871
1300
  const nodeMetrics = metrics.byId.get(node.id);
872
1301
  const nodeDiagnostics = diagnostics.get(node.id) ?? [];
873
- const costStr = node.startup_cost != null && node.total_cost != null ? `${formatCost(node.startup_cost)} - ${formatCost(node.total_cost)}` : node.total_cost != null ? formatCost(node.total_cost) : "-";
874
- const timeStr = hasAnalyzeData && nodeMetrics?.exclusiveTimeMs != null ? formatTime(nodeMetrics.exclusiveTimeMs) : "-";
875
- const rowsStr = node.plan_rows != null ? formatRows(node.plan_rows) : "-";
876
- const actualRowsStr = hasAnalyzeData && node.actual_rows != null ? formatRows(node.actual_rows) : "-";
877
- const rowRatio = getRowEstimateRatio(node);
878
- const ratioStr = rowRatio != null ? rowRatio >= 1 ? formatRatio(rowRatio) : formatRatio(1 / rowRatio) : "-";
1302
+ const row = getExplainTableRow(node, hasAnalyzeData, nodeMetrics);
1303
+ const cells = {
1304
+ operation: () => /* @__PURE__ */ jsx("td", { className: "px-3 py-1.5 whitespace-nowrap", children: /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1", style: { paddingLeft: `${depth * 20}px` }, children: [
1305
+ hasChildren ? /* @__PURE__ */ jsx(
1306
+ "button",
1307
+ {
1308
+ onClick: (e) => {
1309
+ e.stopPropagation();
1310
+ onToggle(node.id);
1311
+ },
1312
+ className: "p-0.5 text-muted hover:text-primary",
1313
+ children: isExpanded ? /* @__PURE__ */ jsx(ChevronDown, { size: 12 }) : /* @__PURE__ */ jsx(ChevronRight, { size: 12 })
1314
+ }
1315
+ ) : /* @__PURE__ */ jsx("span", { className: "w-4" }),
1316
+ /* @__PURE__ */ jsx("span", { className: "text-primary font-medium", children: node.node_type }),
1317
+ nodeDiagnostics.length > 0 && /* @__PURE__ */ jsx(ExplainDiagnosticChips, { diagnostics: nodeDiagnostics, iconsOnly: true })
1318
+ ] }) }, "operation"),
1319
+ relation: () => /* @__PURE__ */ jsx("td", { className: "px-3 py-1.5 text-secondary whitespace-nowrap", children: row.relation ?? "" }, "relation"),
1320
+ cost: () => /* @__PURE__ */ jsx(NumberCell, { value: formatRowCost(row) }, "cost"),
1321
+ estRows: () => /* @__PURE__ */ jsx(
1322
+ NumberCell,
1323
+ {
1324
+ value: row.planRows != null ? formatRows(row.planRows) : "-"
1325
+ },
1326
+ "estRows"
1327
+ ),
1328
+ actualRows: () => /* @__PURE__ */ jsx(
1329
+ NumberCell,
1330
+ {
1331
+ value: row.actualRows != null ? formatRows(row.actualRows) : "-"
1332
+ },
1333
+ "actualRows"
1334
+ ),
1335
+ loops: () => /* @__PURE__ */ jsx(
1336
+ NumberCell,
1337
+ {
1338
+ value: row.actualLoops != null ? formatRows(row.actualLoops) : "-",
1339
+ title: row.actualLoops != null ? String(row.actualLoops) : void 0
1340
+ },
1341
+ "loops"
1342
+ ),
1343
+ selfTime: () => /* @__PURE__ */ jsx(
1344
+ NumberCell,
1345
+ {
1346
+ value: row.exclusiveTimeMs != null ? formatTime(row.exclusiveTimeMs) : "-"
1347
+ },
1348
+ "selfTime"
1349
+ ),
1350
+ estimateGap: () => /* @__PURE__ */ jsx("td", { className: "px-3 py-1.5 text-right whitespace-nowrap", children: /* @__PURE__ */ jsx(
1351
+ "span",
1352
+ {
1353
+ className: clsx(
1354
+ "font-mono",
1355
+ row.estimateGap != null && row.estimateGap >= 4 ? "text-amber-300" : "text-secondary"
1356
+ ),
1357
+ children: row.estimateGap != null ? formatRatio(row.estimateGap) : "-"
1358
+ }
1359
+ ) }, "estimateGap"),
1360
+ filter: () => /* @__PURE__ */ jsx("td", { className: "px-3 py-1.5 text-muted truncate max-w-[200px]", children: row.filter ?? "" }, "filter")
1361
+ };
879
1362
  return /* @__PURE__ */ jsxs(Fragment, { children: [
880
1363
  /* @__PURE__ */ jsxs(
881
1364
  "tr",
@@ -887,44 +1370,7 @@ function TreeRows({
887
1370
  onClick: () => onSelect(node.id),
888
1371
  children: [
889
1372
  /* @__PURE__ */ jsx("td", { className: "px-3 py-1.5 font-mono text-[10px] text-muted whitespace-nowrap", children: nodeMetrics ? `#${nodeMetrics.index}` : "" }),
890
- /* @__PURE__ */ jsx("td", { className: "px-3 py-1.5 whitespace-nowrap", children: /* @__PURE__ */ jsxs(
891
- "div",
892
- {
893
- className: "flex items-center gap-1",
894
- style: { paddingLeft: `${depth * 20}px` },
895
- children: [
896
- hasChildren ? /* @__PURE__ */ jsx(
897
- "button",
898
- {
899
- onClick: (e) => {
900
- e.stopPropagation();
901
- onToggle(node.id);
902
- },
903
- className: "p-0.5 text-muted hover:text-primary",
904
- children: isExpanded ? /* @__PURE__ */ jsx(ChevronDown, { size: 12 }) : /* @__PURE__ */ jsx(ChevronRight, { size: 12 })
905
- }
906
- ) : /* @__PURE__ */ jsx("span", { className: "w-4" }),
907
- /* @__PURE__ */ jsx("span", { className: "text-primary font-medium", children: node.node_type }),
908
- nodeDiagnostics.length > 0 && /* @__PURE__ */ jsx(ExplainDiagnosticChips, { diagnostics: nodeDiagnostics, iconsOnly: true })
909
- ]
910
- }
911
- ) }),
912
- /* @__PURE__ */ jsx("td", { className: "px-3 py-1.5 text-secondary whitespace-nowrap", children: node.relation ?? "" }),
913
- /* @__PURE__ */ jsx("td", { className: "px-3 py-1.5 text-right text-secondary font-mono whitespace-nowrap", children: costStr }),
914
- /* @__PURE__ */ jsx("td", { className: "px-3 py-1.5 text-right text-secondary font-mono whitespace-nowrap", children: rowsStr }),
915
- hasAnalyzeData && /* @__PURE__ */ jsx("td", { className: "px-3 py-1.5 text-right text-secondary font-mono whitespace-nowrap", children: actualRowsStr }),
916
- /* @__PURE__ */ jsx("td", { className: "px-3 py-1.5 text-right text-secondary font-mono whitespace-nowrap", children: timeStr }),
917
- /* @__PURE__ */ jsx("td", { className: "px-3 py-1.5 text-right whitespace-nowrap", children: /* @__PURE__ */ jsx(
918
- "span",
919
- {
920
- className: clsx(
921
- "font-mono",
922
- rowRatio == null ? "text-secondary" : rowRatio >= 4 || rowRatio <= 0.25 ? "text-amber-300" : "text-secondary"
923
- ),
924
- children: ratioStr
925
- }
926
- ) }),
927
- /* @__PURE__ */ jsx("td", { className: "px-3 py-1.5 text-muted truncate max-w-[200px]", children: node.filter ?? "" })
1373
+ columns.map((column) => cells[column]())
928
1374
  ]
929
1375
  }
930
1376
  ),
@@ -939,12 +1385,29 @@ function TreeRows({
939
1385
  onSelect,
940
1386
  hasAnalyzeData,
941
1387
  metrics,
942
- diagnostics
1388
+ diagnostics,
1389
+ columns
943
1390
  },
944
1391
  child.id
945
1392
  ))
946
1393
  ] });
947
1394
  }
1395
+ function formatRowCost(row) {
1396
+ if (row.startupCost != null && row.totalCost != null) {
1397
+ return `${formatCost(row.startupCost)} - ${formatCost(row.totalCost)}`;
1398
+ }
1399
+ return row.totalCost != null ? formatCost(row.totalCost) : "-";
1400
+ }
1401
+ function NumberCell({ value, title }) {
1402
+ return /* @__PURE__ */ jsx(
1403
+ "td",
1404
+ {
1405
+ className: "px-3 py-1.5 text-right text-secondary font-mono whitespace-nowrap",
1406
+ title,
1407
+ children: value
1408
+ }
1409
+ );
1410
+ }
948
1411
  function collectExpandedIds(root) {
949
1412
  const ids = /* @__PURE__ */ new Set();
950
1413
  function walk(node) {
@@ -978,17 +1441,13 @@ function ExplainDiagramView({
978
1441
  metrics,
979
1442
  diagnostics,
980
1443
  selectedId,
981
- onSelect
1444
+ onSelect,
1445
+ onCopyDetails
982
1446
  }) {
983
1447
  const { t } = useTranslation();
984
- const availableKinds = useMemo(
985
- () => getAvailableMetricKinds(metrics),
986
- [metrics]
987
- );
1448
+ const availableKinds = useMemo(() => getAvailableMetricKinds(metrics), [metrics]);
988
1449
  const defaultKind = useMemo(() => getDefaultMetricKind(metrics), [metrics]);
989
- const [requestedKind, setRequestedKind] = useState(
990
- null
991
- );
1450
+ const [requestedKind, setRequestedKind] = useState(null);
992
1451
  const metricKind = requestedKind != null && availableKinds.includes(requestedKind) ? requestedKind : defaultKind;
993
1452
  const rows = useMemo(() => {
994
1453
  const nodesById = new Map(
@@ -1045,7 +1504,9 @@ function ExplainDiagramView({
1045
1504
  /* @__PURE__ */ jsx("td", { className: "px-1 py-1.5 align-top", children: /* @__PURE__ */ jsxs(
1046
1505
  "div",
1047
1506
  {
1048
- style: { paddingLeft: `${nodeMetrics.depth * 12}px` },
1507
+ style: {
1508
+ paddingLeft: `${nodeMetrics.depth * 12}px`
1509
+ },
1049
1510
  className: "min-w-0",
1050
1511
  children: [
1051
1512
  /* @__PURE__ */ jsx("div", { className: "truncate font-medium text-primary", children: node.node_type }),
@@ -1086,7 +1547,8 @@ function ExplainDiagramView({
1086
1547
  node: selectedNode,
1087
1548
  hasAnalyzeData: plan.has_analyze_data,
1088
1549
  metrics: selectedNode ? metrics.byId.get(selectedNode.id) ?? null : null,
1089
- diagnostics: selectedNode ? diagnostics.get(selectedNode.id) ?? [] : []
1550
+ diagnostics: selectedNode ? diagnostics.get(selectedNode.id) ?? [] : [],
1551
+ onCopyDetails
1090
1552
  }
1091
1553
  ) })
1092
1554
  ] });
@@ -1202,10 +1664,7 @@ function compareByTimeThenCount(a, b) {
1202
1664
  }
1203
1665
  function ExplainStatsView({ plan, metrics }) {
1204
1666
  const { t } = useTranslation();
1205
- const stats = useMemo(
1206
- () => getExplainPlanStats(plan, metrics),
1207
- [plan, metrics]
1208
- );
1667
+ const stats = useMemo(() => getExplainPlanStats(plan, metrics), [plan, metrics]);
1209
1668
  return /* @__PURE__ */ jsxs("div", { className: "h-full overflow-y-auto px-4 py-3 text-xs", children: [
1210
1669
  /* @__PURE__ */ jsxs("div", { className: "mb-4 flex flex-wrap gap-2", children: [
1211
1670
  /* @__PURE__ */ jsx(
@@ -1243,8 +1702,16 @@ function ExplainStatsView({ plan, metrics }) {
1243
1702
  {
1244
1703
  columns: [
1245
1704
  { key: "type", label: t("editor.visualExplain.nodeType") },
1246
- { key: "count", label: t("editor.visualExplain.stats.count"), align: "right" },
1247
- { key: "time", label: t("editor.visualExplain.selfTime"), align: "right" },
1705
+ {
1706
+ key: "count",
1707
+ label: t("editor.visualExplain.stats.count"),
1708
+ align: "right"
1709
+ },
1710
+ {
1711
+ key: "time",
1712
+ label: t("editor.visualExplain.selfTime"),
1713
+ align: "right"
1714
+ },
1248
1715
  { key: "share", label: t("editor.visualExplain.stats.share") }
1249
1716
  ]
1250
1717
  }
@@ -1294,7 +1761,10 @@ function ExplainStatsView({ plan, metrics }) {
1294
1761
  StatsHeaderRow,
1295
1762
  {
1296
1763
  columns: [
1297
- { key: "index", label: t("editor.visualExplain.stats.indexName") },
1764
+ {
1765
+ key: "index",
1766
+ label: t("editor.visualExplain.stats.indexName")
1767
+ },
1298
1768
  { key: "relation", label: t("editor.visualExplain.relation") },
1299
1769
  {
1300
1770
  key: "scans",
@@ -1361,16 +1831,9 @@ function ShareBar({ share }) {
1361
1831
  ] })
1362
1832
  ] });
1363
1833
  }
1364
- function ExplainOverviewBar({
1365
- plan,
1366
- metrics,
1367
- onSelectNode
1368
- }) {
1834
+ function ExplainOverviewBar({ plan, metrics, onSelectNode }) {
1369
1835
  const { t } = useTranslation();
1370
- const summary = useMemo(
1371
- () => getExplainPlanSummary(plan, metrics),
1372
- [plan, metrics]
1373
- );
1836
+ const summary = useMemo(() => getExplainPlanSummary(plan, metrics), [plan, metrics]);
1374
1837
  const legend = useMemo(() => getExplainDriverLegend(plan), [plan]);
1375
1838
  const [overviewExpanded, setOverviewExpanded] = useState(true);
1376
1839
  const findings = [