@hienlh/ppm 0.9.65 → 0.9.66

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 (171) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/web/assets/{_basePickBy-3Xe18azI.js → _basePickBy-5PGDJbfF.js} +1 -1
  3. package/dist/web/assets/{_baseUniq-Yy35llnn.js → _baseUniq-BT4Ow4Kk.js} +1 -1
  4. package/dist/web/assets/{api-settings-CgBII8jW.js → api-settings-Bn-bIxD1.js} +1 -1
  5. package/dist/web/assets/{arc-B9n1Gvb5.js → arc-BAOivWpI.js} +1 -1
  6. package/dist/web/assets/architecture-PBZL5I3N-DEO2f3VD.js +1 -0
  7. package/dist/web/assets/{architectureDiagram-2XIMDMQ5-DqAZP_F6.js → architectureDiagram-2XIMDMQ5-Z-4eN4za.js} +1 -1
  8. package/dist/web/assets/{blockDiagram-WCTKOSBZ-h3cDF2vI.js → blockDiagram-WCTKOSBZ-BCLqzhuZ.js} +1 -1
  9. package/dist/web/assets/{c4Diagram-IC4MRINW--pF1r5lr.js → c4Diagram-IC4MRINW-0Vp0Jeas.js} +1 -1
  10. package/dist/web/assets/channel-By7bn0Yq.js +1 -0
  11. package/dist/web/assets/chat-tab-BSExiIao.js +10 -0
  12. package/dist/web/assets/{chunk-4BX2VUAB-C3aZvW7B.js → chunk-4BX2VUAB-D4tOov49.js} +1 -1
  13. package/dist/web/assets/{chunk-55IACEB6-D5cABeB9.js → chunk-55IACEB6-DJ6BynZ4.js} +1 -1
  14. package/dist/web/assets/{chunk-7E7YKBS2-CkFGv6Zs.js → chunk-7E7YKBS2-CiyUJxNI.js} +1 -1
  15. package/dist/web/assets/{chunk-7R4GIKGN-Dvbyu4Zw.js → chunk-7R4GIKGN-Dv-4cAYn.js} +2 -2
  16. package/dist/web/assets/{chunk-C72U2L5F-CtqKiH4q.js → chunk-C72U2L5F-D21mS_6G.js} +1 -1
  17. package/dist/web/assets/{chunk-EGIJ26TM-Cpr87sBR.js → chunk-EGIJ26TM-DzqmU2Z7.js} +1 -1
  18. package/dist/web/assets/{chunk-FMBD7UC4-D23YVTOU.js → chunk-FMBD7UC4-DXncblvW.js} +1 -1
  19. package/dist/web/assets/{chunk-GEFDOKGD-tDjHsAUs.js → chunk-GEFDOKGD-D-pKjlVd.js} +1 -1
  20. package/dist/web/assets/chunk-GLR3WWYH-DKikpoJM.js +2 -0
  21. package/dist/web/assets/chunk-HHEYEP7N-C7vxA5i9.js +1 -0
  22. package/dist/web/assets/{chunk-JSJVCQXG-BBmymCjA.js → chunk-JSJVCQXG-99JzIdPr.js} +1 -1
  23. package/dist/web/assets/{chunk-KX2RTZJC-DP36BDiU.js → chunk-KX2RTZJC-CRq1OBZv.js} +1 -1
  24. package/dist/web/assets/{chunk-KYZI473N-Djw13C-3.js → chunk-KYZI473N-Bb0MCaIO.js} +1 -1
  25. package/dist/web/assets/{chunk-L3YUKLVL-HG_eMj_C.js → chunk-L3YUKLVL-C7qGJrfV.js} +1 -1
  26. package/dist/web/assets/{chunk-MX3YWQON-C2UEioMs.js → chunk-MX3YWQON-BpS_PtKp.js} +1 -1
  27. package/dist/web/assets/{chunk-NQ4KR5QH-DXUTQ-BL.js → chunk-NQ4KR5QH-z_blpjxi.js} +1 -1
  28. package/dist/web/assets/{chunk-O4XLMI2P-BsUWb9d0.js → chunk-O4XLMI2P-nDhi_cVu.js} +1 -1
  29. package/dist/web/assets/{chunk-OZEHJAEY-rG0P22U9.js → chunk-OZEHJAEY-BXhYx3nO.js} +1 -1
  30. package/dist/web/assets/{chunk-PQ6SQG4A-DX0xW7kO.js → chunk-PQ6SQG4A-TF58UVMU.js} +1 -1
  31. package/dist/web/assets/{chunk-PU5JKC2W-C7Gry6md.js → chunk-PU5JKC2W-ek7k4QVB.js} +1 -1
  32. package/dist/web/assets/chunk-QZHKN3VN-CYaTbeZf.js +1 -0
  33. package/dist/web/assets/{chunk-R5LLSJPH-CMY0PkRK.js → chunk-R5LLSJPH-CFwSJijQ.js} +1 -1
  34. package/dist/web/assets/{chunk-WL4C6EOR-CXuQvlyu.js → chunk-WL4C6EOR-ByUrSRin.js} +1 -1
  35. package/dist/web/assets/{chunk-XIRO2GV7-DRJEb7Zb.js → chunk-XIRO2GV7-Djlmrely.js} +1 -1
  36. package/dist/web/assets/{chunk-XPW4576I-BPEX8KhL.js → chunk-XPW4576I-BPQQBakK.js} +1 -1
  37. package/dist/web/assets/{chunk-XZSTWKYB-Cb0iqycX.js → chunk-XZSTWKYB-DxAOx4hG.js} +1 -1
  38. package/dist/web/assets/{chunk-YBOYWFTD-av5aeHLq.js → chunk-YBOYWFTD-rQG3QH5s.js} +1 -1
  39. package/dist/web/assets/classDiagram-VBA2DB6C-BA8Nj-_C.js +1 -0
  40. package/dist/web/assets/classDiagram-v2-RAHNMMFH-DjYu-6mn.js +1 -0
  41. package/dist/web/assets/clone-LRxlvnMj.js +1 -0
  42. package/dist/web/assets/code-editor-BiGbDwge.js +5 -0
  43. package/dist/web/assets/{cose-bilkent-S5V4N54A-qudEiMCT.js → cose-bilkent-S5V4N54A-B_AWZsOP.js} +1 -1
  44. package/dist/web/assets/csv-parser-CNNw2RVA.js +6 -0
  45. package/dist/web/assets/{csv-preview-sx6DC51G.js → csv-preview-D2pJJj3K.js} +3 -8
  46. package/dist/web/assets/{dagre-BFcnKyBF.js → dagre-DHq9bhnd.js} +1 -1
  47. package/dist/web/assets/{dagre-KLK3FWXG-C3O-MTLf.js → dagre-KLK3FWXG-BdJr7Byp.js} +1 -1
  48. package/dist/web/assets/database-viewer-NyjI--ui.js +7 -0
  49. package/dist/web/assets/{diagram-E7M64L7V-DxPjK7_c.js → diagram-E7M64L7V-_db4pBVA.js} +1 -1
  50. package/dist/web/assets/{diagram-IFDJBPK2-sqTog_XV.js → diagram-IFDJBPK2-xKoeuiJx.js} +1 -1
  51. package/dist/web/assets/{diagram-P4PSJMXO-hzmp0GHK.js → diagram-P4PSJMXO-C8tjJsev.js} +1 -1
  52. package/dist/web/assets/diff-viewer-p2tGGtgk.js +4 -0
  53. package/dist/web/assets/dist-CbZVY8RS.js +1 -0
  54. package/dist/web/assets/{erDiagram-INFDFZHY-DLeYhAAT.js → erDiagram-INFDFZHY-BSh2z9Df.js} +1 -1
  55. package/dist/web/assets/es2015-CFSGWybX.js +41 -0
  56. package/dist/web/assets/{extension-webview-2XjXXtyy.js → extension-webview-CUMHM0dE.js} +1 -1
  57. package/dist/web/assets/{flowDiagram-PKNHOUZH-CRxlE9Sr.js → flowDiagram-PKNHOUZH-oYaovqyp.js} +1 -1
  58. package/dist/web/assets/{ganttDiagram-A5KZAMGK-BdjmoMLS.js → ganttDiagram-A5KZAMGK-DmL26q2P.js} +1 -1
  59. package/dist/web/assets/git-graph-mKama5oa.js +1 -0
  60. package/dist/web/assets/gitGraph-HDMCJU4V-Bwna3and.js +1 -0
  61. package/dist/web/assets/{gitGraphDiagram-K3NZZRJ6-BeHSX7kk.js → gitGraphDiagram-K3NZZRJ6-CMoukSrY.js} +1 -1
  62. package/dist/web/assets/{graphlib-Duh_bWLa.js → graphlib-BcsNnGcW.js} +1 -1
  63. package/dist/web/assets/index-BoNuMDkJ.css +2 -0
  64. package/dist/web/assets/index-KxgzhD-N.js +30 -0
  65. package/dist/web/assets/info-3K5VOQVL-_vRxVNUm.js +1 -0
  66. package/dist/web/assets/infoDiagram-LFFYTUFH-DWwumDkq.js +2 -0
  67. package/dist/web/assets/{isEmpty-B9L-Ge-H.js → isEmpty-bnrF3Qbc.js} +1 -1
  68. package/dist/web/assets/{ishikawaDiagram-PHBUUO56-Cu0Rt1Ok.js → ishikawaDiagram-PHBUUO56-D05_LyL7.js} +1 -1
  69. package/dist/web/assets/{journeyDiagram-4ABVD52K-CgDI-UG4.js → journeyDiagram-4ABVD52K-B_L20qMe.js} +1 -1
  70. package/dist/web/assets/{kanban-definition-K7BYSVSG-h4g10UHL.js → kanban-definition-K7BYSVSG-CZ535BbZ.js} +1 -1
  71. package/dist/web/assets/keybindings-store-BqfuJQDT.js +1 -0
  72. package/dist/web/assets/{line-B75-Rx70.js → line-CVvo3dRu.js} +1 -1
  73. package/dist/web/assets/{linear-Bcjv9FQt.js → linear-DP4mkX3m.js} +1 -1
  74. package/dist/web/assets/{markdown-renderer-Bb7OSpxF.js → markdown-renderer-NhtCBdv0.js} +5 -5
  75. package/dist/web/assets/{mermaid-parser.core-8u2leTXI.js → mermaid-parser.core-C7UwoIh6.js} +2 -2
  76. package/dist/web/assets/{mindmap-definition-YRQLILUH-BaOBwb-W.js → mindmap-definition-YRQLILUH-x0MTutJp.js} +1 -1
  77. package/dist/web/assets/{ordinal-LFEjVtwQ.js → ordinal-_K3x1fkz.js} +1 -1
  78. package/dist/web/assets/packet-RMMSAZCW-DY5PNnZU.js +1 -0
  79. package/dist/web/assets/pie-UPGHQEXC-BHncZutv.js +1 -0
  80. package/dist/web/assets/{pieDiagram-SKSYHLDU-At5Kz0KK.js → pieDiagram-SKSYHLDU-C1Gjrtzy.js} +1 -1
  81. package/dist/web/assets/{port-forwarding-tab-bD8MKumH.js → port-forwarding-tab-DCeyw77P.js} +1 -1
  82. package/dist/web/assets/postgres-viewer-DSxa4fF1.js +13 -0
  83. package/dist/web/assets/{quadrantDiagram-337W2JSQ-CdjGIDfw.js → quadrantDiagram-337W2JSQ-C8bzJCjQ.js} +1 -1
  84. package/dist/web/assets/radar-KQ55EAFF-DH0AOkUy.js +1 -0
  85. package/dist/web/assets/{requirementDiagram-Z7DCOOCP-B9F_Cx_p.js → requirementDiagram-Z7DCOOCP-pQyah6WB.js} +1 -1
  86. package/dist/web/assets/{sankeyDiagram-WA2Y5GQK-RolPi8bU.js → sankeyDiagram-WA2Y5GQK-T6RgG-N8.js} +1 -1
  87. package/dist/web/assets/{sequenceDiagram-2WXFIKYE-DM-tMAhx.js → sequenceDiagram-2WXFIKYE-BQDJ4CVs.js} +1 -1
  88. package/dist/web/assets/settings-tab-9NIbwd-q.js +1 -0
  89. package/dist/web/assets/sql-completion-provider-DM9Qov6L.js +1 -0
  90. package/dist/web/assets/sql-query-editor-CMcXl1UJ.js +3 -0
  91. package/dist/web/assets/sqlite-viewer-DLBgkBb1.js +1 -0
  92. package/dist/web/assets/{stateDiagram-RAJIS63D-C4EMl6jf.js → stateDiagram-RAJIS63D-66vhiIuk.js} +1 -1
  93. package/dist/web/assets/stateDiagram-v2-FVOUBMTO-BGVqj_g9.js +1 -0
  94. package/dist/web/assets/{terminal-tab-Cq6vQ9W9.js → terminal-tab-XuU6mUdf.js} +2 -2
  95. package/dist/web/assets/text-wrap-BWNOVswA.js +1 -0
  96. package/dist/web/assets/{timeline-definition-YZTLITO2-A4PN_Efm.js → timeline-definition-YZTLITO2-DwZqB3nn.js} +1 -1
  97. package/dist/web/assets/treemap-KZPCXAKY-B2Xkyv-K.js +1 -0
  98. package/dist/web/assets/use-monaco-theme-BWw-hkx4.js +11 -0
  99. package/dist/web/assets/{vennDiagram-LZ73GAT5-ywK7LMaH.js → vennDiagram-LZ73GAT5-s9Z71fz-.js} +1 -1
  100. package/dist/web/assets/{xychartDiagram-JWTSCODW-DylHYNtJ.js → xychartDiagram-JWTSCODW-DRa_TH4B.js} +1 -1
  101. package/dist/web/index.html +7 -6
  102. package/dist/web/sw.js +1 -1
  103. package/package.json +1 -1
  104. package/src/cli/commands/bot-cmd.ts +4 -0
  105. package/src/cli/commands/db-cmd.ts +4 -3
  106. package/src/server/routes/database.ts +126 -9
  107. package/src/services/cloud.service.ts +2 -1
  108. package/src/services/database/sqlite-adapter.ts +1 -0
  109. package/src/services/db.service.ts +42 -3
  110. package/src/services/postgres.service.ts +37 -6
  111. package/src/services/sqlite.service.ts +18 -4
  112. package/src/services/table-cache.service.ts +2 -3
  113. package/src/types/database.ts +2 -0
  114. package/src/web/components/database/connection-form-dialog.tsx +17 -8
  115. package/src/web/components/database/connection-list.tsx +191 -139
  116. package/src/web/components/database/data-grid.tsx +714 -0
  117. package/src/web/components/database/database-sidebar.tsx +4 -1
  118. package/src/web/components/database/database-viewer.tsx +204 -225
  119. package/src/web/components/database/export-button.tsx +100 -0
  120. package/src/web/components/database/sql-completion-provider.ts +301 -0
  121. package/src/web/components/database/sql-query-editor.tsx +123 -0
  122. package/src/web/components/database/use-connections.ts +21 -1
  123. package/src/web/components/database/use-database.ts +59 -7
  124. package/src/web/components/editor/code-editor.tsx +176 -12
  125. package/src/web/components/sqlite/sqlite-query-editor.tsx +3 -90
  126. package/src/web/components/sqlite/sqlite-viewer.tsx +0 -2
  127. package/src/web/components/sqlite/use-sqlite.ts +1 -1
  128. package/dist/web/assets/architecture-PBZL5I3N-CFzkFKEL.js +0 -1
  129. package/dist/web/assets/channel-C2fMafck.js +0 -1
  130. package/dist/web/assets/chat-tab-CfdMDCBK.js +0 -10
  131. package/dist/web/assets/chunk-GLR3WWYH-DBdWQ3zy.js +0 -2
  132. package/dist/web/assets/chunk-HHEYEP7N-BBw_z0fW.js +0 -1
  133. package/dist/web/assets/chunk-QZHKN3VN-DFKFM_C1.js +0 -1
  134. package/dist/web/assets/classDiagram-VBA2DB6C-Dp4Kk3Yb.js +0 -1
  135. package/dist/web/assets/classDiagram-v2-RAHNMMFH-D8IvcV_B.js +0 -1
  136. package/dist/web/assets/clone-B2hUek6n.js +0 -1
  137. package/dist/web/assets/code-editor-DhCfJIpG.js +0 -2
  138. package/dist/web/assets/database-viewer-DLkAUBpm.js +0 -1
  139. package/dist/web/assets/diff-viewer-DWVWsekJ.js +0 -4
  140. package/dist/web/assets/dist-C40JmyoH.js +0 -13
  141. package/dist/web/assets/dist-DRTW9IWi.js +0 -41
  142. package/dist/web/assets/git-graph-Ds5bs1cM.js +0 -1
  143. package/dist/web/assets/gitGraph-HDMCJU4V-CNlas3Rz.js +0 -1
  144. package/dist/web/assets/index-8b0LM6IC.js +0 -30
  145. package/dist/web/assets/index-BnMECpW3.css +0 -2
  146. package/dist/web/assets/info-3K5VOQVL-BDzTLc11.js +0 -1
  147. package/dist/web/assets/infoDiagram-LFFYTUFH-ZZmpgc6t.js +0 -2
  148. package/dist/web/assets/keybindings-store-C06Z0Zhk.js +0 -1
  149. package/dist/web/assets/packet-RMMSAZCW-IVa5F-go.js +0 -1
  150. package/dist/web/assets/pie-UPGHQEXC-CvXHKAzp.js +0 -1
  151. package/dist/web/assets/postgres-viewer-C3OND65T.js +0 -1
  152. package/dist/web/assets/radar-KQ55EAFF-Z-Tr5wtS.js +0 -1
  153. package/dist/web/assets/settings-tab-cuMIkUNV.js +0 -1
  154. package/dist/web/assets/sqlite-viewer-CpjnwDtk.js +0 -1
  155. package/dist/web/assets/stateDiagram-v2-FVOUBMTO-B-UjZch3.js +0 -1
  156. package/dist/web/assets/treemap-KZPCXAKY-C9TYRE0k.js +0 -1
  157. package/dist/web/assets/use-monaco-theme-BH9sQ-Yu.js +0 -11
  158. /package/dist/web/assets/{api-client-BKIT_Qeg.js → api-client-BfBM3I7n.js} +0 -0
  159. /package/dist/web/assets/{array-DqLCdDFv.js → array-B9UHiPd-.js} +0 -0
  160. /package/dist/web/assets/{cytoscape.esm-CWPXKqbJ.js → cytoscape.esm-BW-DbntU.js} +0 -0
  161. /package/dist/web/assets/{defaultLocale-CrJzLgRD.js → defaultLocale-5eAKkKJC.js} +0 -0
  162. /package/dist/web/assets/{dist-Cep75xXf.js → dist-CSJdAyA9.js} +0 -0
  163. /package/dist/web/assets/{init-C0r9Gk5G.js → init-DlZdxViB.js} +0 -0
  164. /package/dist/web/assets/{isArrayLikeObject-CGBoxvCD.js → isArrayLikeObject-B_v2FtYn.js} +0 -0
  165. /package/dist/web/assets/{katex-DzXRfQ_m.js → katex-Bqvo_ZG0.js} +0 -0
  166. /package/dist/web/assets/{lib-mag4ySk-.js → lib-DurwGtQO.js} +0 -0
  167. /package/dist/web/assets/{math-y9zN1W-N.js → math-069Z4SuC.js} +0 -0
  168. /package/dist/web/assets/{path-DIKpVbHL.js → path-6uRLdFF7.js} +0 -0
  169. /package/dist/web/assets/{rough.esm-nHaDi0Kw.js → rough.esm-JX0wREDd.js} +0 -0
  170. /package/dist/web/assets/{src-Dw4QhedI.js → src-BqX54PbV.js} +0 -0
  171. /package/dist/web/assets/{utils-DMiycH3O.js → utils-BNytJOb1.js} +0 -0
@@ -0,0 +1,714 @@
1
+ import { useState, useCallback, useMemo, useRef, memo, useEffect } from "react";
2
+ import { Loader2, ChevronLeft, ChevronRight, ChevronUp, ChevronDown, Trash2, Plus, Search, X, Eye, Filter, Pin, PinOff } from "lucide-react";
3
+ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
4
+ import Editor from "@monaco-editor/react";
5
+ import { useMonacoTheme } from "@/lib/use-monaco-theme";
6
+ import type { DbColumnInfo } from "./use-database";
7
+ import { ExportButton } from "./export-button";
8
+
9
+ interface DataGridProps {
10
+ tableData: { columns: string[]; rows: Record<string, unknown>[]; total: number; limit: number } | null;
11
+ schema: DbColumnInfo[];
12
+ loading: boolean;
13
+ page: number;
14
+ onPageChange: (p: number) => void;
15
+ onCellUpdate: (pkCol: string, pkVal: unknown, col: string, val: unknown) => void;
16
+ onRowDelete?: (pkCol: string, pkVal: unknown) => void;
17
+ orderBy: string | null;
18
+ orderDir: "ASC" | "DESC";
19
+ onToggleSort: (column: string) => void;
20
+ onBulkDelete?: (pkColumn: string, pkValues: unknown[]) => void;
21
+ onInsertRow?: (values: Record<string, unknown>) => Promise<void>;
22
+ connectionId?: number;
23
+ selectedTable?: string | null;
24
+ selectedSchema?: string;
25
+ connectionName?: string;
26
+ /** Called when column ILIKE filters change — parent builds WHERE clause */
27
+ onColumnFilter?: (filters: Record<string, string>) => void;
28
+ }
29
+
30
+ export function DataGrid({
31
+ tableData, schema, loading, page, onPageChange, onCellUpdate, onRowDelete,
32
+ orderBy, orderDir, onToggleSort,
33
+ onBulkDelete, onInsertRow,
34
+ connectionId, selectedTable, selectedSchema, connectionName, onColumnFilter,
35
+ }: DataGridProps) {
36
+ const [editingCell, setEditingCell] = useState<{ rowIdx: number; col: string } | null>(null);
37
+ const [editValue, setEditValue] = useState("");
38
+ const [confirmDeleteIdx, setConfirmDeleteIdx] = useState<number | null>(null);
39
+ const [globalFilter, setGlobalFilter] = useState("");
40
+ const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
41
+ const [insertMode, setInsertMode] = useState(false);
42
+ const [insertValues, setInsertValues] = useState<Record<string, string>>({});
43
+ const [insertError, setInsertError] = useState<string | null>(null);
44
+ const [confirmBulkDelete, setConfirmBulkDelete] = useState(false);
45
+ const [viewerCell, setViewerCell] = useState<{ col: string; value: string } | null>(null);
46
+ const [pinnedCols, setPinnedCols] = useState<Set<string>>(new Set());
47
+ const [pinnedRows, setPinnedRows] = useState<Set<number>>(new Set());
48
+ const [colFilters, setColFilters] = useState<Record<string, string>>({});
49
+ const [filterOpenCol, setFilterOpenCol] = useState<string | null>(null);
50
+
51
+ const pkCol = useMemo(() => {
52
+ const explicit = schema.find((c) => c.pk)?.name;
53
+ if (explicit) return explicit;
54
+ const idCol = schema.find((c) => c.name.toLowerCase() === "id");
55
+ return idCol?.name ?? null;
56
+ }, [schema]);
57
+
58
+ // Refs for cell renderers — avoid column memo rebuild on every state change
59
+ const editingRef = useRef(editingCell);
60
+ editingRef.current = editingCell;
61
+ const editValueRef = useRef(editValue);
62
+ editValueRef.current = editValue;
63
+ const selectedRowsRef = useRef(selectedRows);
64
+ selectedRowsRef.current = selectedRows;
65
+ const confirmDeleteRef = useRef(confirmDeleteIdx);
66
+ confirmDeleteRef.current = confirmDeleteIdx;
67
+
68
+ const startEdit = useCallback((rowIdx: number, col: string, val: unknown) => {
69
+ setEditingCell({ rowIdx, col });
70
+ if (val == null) setEditValue("");
71
+ else if (typeof val === "object") setEditValue(JSON.stringify(val));
72
+ else setEditValue(String(val));
73
+ }, []);
74
+
75
+ const commitEdit = useCallback(() => {
76
+ const ec = editingRef.current;
77
+ if (!ec || !tableData || !pkCol) return;
78
+ const row = tableData.rows[ec.rowIdx];
79
+ if (!row) return;
80
+ const oldVal = row[ec.col];
81
+ if (String(oldVal ?? "") !== editValueRef.current) {
82
+ onCellUpdate(pkCol, row[pkCol], ec.col, editValueRef.current === "" ? null : editValueRef.current);
83
+ }
84
+ setEditingCell(null);
85
+ }, [tableData, pkCol, onCellUpdate]);
86
+
87
+ const cancelEdit = useCallback(() => setEditingCell(null), []);
88
+
89
+ const handleDelete = useCallback((rowIdx: number) => {
90
+ if (!tableData || !pkCol || !onRowDelete) return;
91
+ const row = tableData.rows[rowIdx];
92
+ if (!row) return;
93
+ onRowDelete(pkCol, row[pkCol]);
94
+ setConfirmDeleteIdx(null);
95
+ }, [tableData, pkCol, onRowDelete]);
96
+
97
+ const togglePinCol = useCallback((col: string) => {
98
+ setPinnedCols((prev) => {
99
+ const next = new Set(prev);
100
+ if (next.has(col)) next.delete(col); else next.add(col);
101
+ return next;
102
+ });
103
+ }, []);
104
+
105
+ const togglePinRow = useCallback((idx: number) => {
106
+ setPinnedRows((prev) => {
107
+ const next = new Set(prev);
108
+ if (next.has(idx)) next.delete(idx); else next.add(idx);
109
+ return next;
110
+ });
111
+ }, []);
112
+
113
+ const updateColFilter = useCallback((col: string, val: string) => {
114
+ setColFilters((prev) => {
115
+ const next = { ...prev };
116
+ if (val) next[col] = val; else delete next[col];
117
+ return next;
118
+ });
119
+ }, []);
120
+
121
+ // Notify parent when column filters change
122
+ const colFiltersRef = useRef(colFilters);
123
+ useEffect(() => {
124
+ if (colFiltersRef.current !== colFilters) {
125
+ colFiltersRef.current = colFilters;
126
+ onColumnFilter?.(colFilters);
127
+ }
128
+ }, [colFilters, onColumnFilter]);
129
+
130
+ const toggleRowSelection = useCallback((idx: number) => {
131
+ setSelectedRows((prev) => {
132
+ const next = new Set(prev);
133
+ if (next.has(idx)) next.delete(idx); else next.add(idx);
134
+ return next;
135
+ });
136
+ }, []);
137
+
138
+ const toggleAllRows = useCallback(() => {
139
+ if (!tableData) return;
140
+ setSelectedRows((prev) => {
141
+ if (prev.size === tableData.rows.length) return new Set();
142
+ return new Set(tableData.rows.map((_, i) => i));
143
+ });
144
+ }, [tableData]);
145
+
146
+ const handleBulkDelete = useCallback(() => {
147
+ if (!tableData || !pkCol || !onBulkDelete) return;
148
+ const pkValues = Array.from(selectedRows).map((idx) => tableData.rows[idx]?.[pkCol]).filter((v) => v != null);
149
+ onBulkDelete(pkCol, pkValues);
150
+ setSelectedRows(new Set());
151
+ setConfirmBulkDelete(false);
152
+ }, [tableData, pkCol, onBulkDelete, selectedRows]);
153
+
154
+ const handleInsertSave = useCallback(async () => {
155
+ if (!onInsertRow) return;
156
+ setInsertError(null);
157
+ try {
158
+ const values: Record<string, unknown> = {};
159
+ for (const [k, v] of Object.entries(insertValues)) {
160
+ if (v !== "") values[k] = v;
161
+ }
162
+ await onInsertRow(values);
163
+ setInsertMode(false);
164
+ setInsertValues({});
165
+ } catch (e) {
166
+ setInsertError((e as Error).message);
167
+ }
168
+ }, [onInsertRow, insertValues]);
169
+
170
+ // Filter rows by global filter (simple client-side text match)
171
+ const filteredRows = useMemo(() => {
172
+ if (!tableData || !globalFilter) return tableData?.rows ?? [];
173
+ const lf = globalFilter.toLowerCase();
174
+ return tableData.rows.filter((row) =>
175
+ tableData.columns.some((col) => String(row[col] ?? "").toLowerCase().includes(lf))
176
+ );
177
+ }, [tableData, globalFilter]);
178
+
179
+ const containerRef = useRef<HTMLDivElement>(null);
180
+
181
+ // Cmd/Ctrl+A → select all, Cmd/Ctrl+C → copy selected as TSV (Excel-compatible)
182
+ useEffect(() => {
183
+ const el = containerRef.current;
184
+ if (!el) return;
185
+ const handler = (e: KeyboardEvent) => {
186
+ const mod = e.metaKey || e.ctrlKey;
187
+ if (!mod || !tableData) return;
188
+ // Skip if focus is in an input/textarea
189
+ const tag = (e.target as HTMLElement)?.tagName;
190
+ if (tag === "INPUT" || tag === "TEXTAREA") return;
191
+
192
+ if (e.key === "a") {
193
+ e.preventDefault();
194
+ setSelectedRows(new Set(tableData.rows.map((_, i) => i)));
195
+ }
196
+ if (e.key === "c" && selectedRows.size > 0) {
197
+ e.preventDefault();
198
+ const cols = tableData.columns;
199
+ const header = cols.join("\t");
200
+ const rows = Array.from(selectedRows)
201
+ .sort((a, b) => a - b)
202
+ .map((i) => cols.map((c) => {
203
+ const v = tableData.rows[i]?.[c];
204
+ if (v == null) return "";
205
+ if (typeof v === "object") return JSON.stringify(v);
206
+ return String(v);
207
+ }).join("\t"));
208
+ navigator.clipboard.writeText([header, ...rows].join("\n"));
209
+ }
210
+ };
211
+ el.addEventListener("keydown", handler);
212
+ return () => el.removeEventListener("keydown", handler);
213
+ }, [tableData, selectedRows]);
214
+
215
+ if (!tableData) {
216
+ return (
217
+ <div className="flex items-center justify-center h-full text-xs text-muted-foreground">
218
+ {loading ? <Loader2 className="size-4 animate-spin" /> : "Select a table"}
219
+ </div>
220
+ );
221
+ }
222
+
223
+ const totalPages = Math.ceil(tableData.total / tableData.limit) || 1;
224
+ const hasSelection = selectedRows.size > 0;
225
+ const allSelected = selectedRows.size === tableData.rows.length && tableData.rows.length > 0;
226
+
227
+ // Build ordered column list: pinned first, then unpinned
228
+ const orderedCols = useMemo(() => {
229
+ const pinned = tableData.columns.filter((c) => pinnedCols.has(c));
230
+ const unpinned = tableData.columns.filter((c) => !pinnedCols.has(c));
231
+ return [...pinned, ...unpinned];
232
+ }, [tableData.columns, pinnedCols]);
233
+
234
+ // Measure actual column widths and header height from DOM
235
+ const theadRef = useRef<HTMLTableSectionElement>(null);
236
+ const [headerHeight, setHeaderHeight] = useState(0);
237
+ const [colWidths, setColWidths] = useState<Map<string, number>>(new Map());
238
+
239
+ useEffect(() => {
240
+ const thead = theadRef.current;
241
+ if (!thead) return;
242
+ const measure = () => {
243
+ setHeaderHeight(thead.offsetHeight);
244
+ // Measure each <th> by data-col attribute
245
+ const widths = new Map<string, number>();
246
+ thead.querySelectorAll<HTMLElement>("th[data-col]").forEach((th) => {
247
+ widths.set(th.dataset.col!, th.offsetWidth);
248
+ });
249
+ // Also measure checkbox th
250
+ const cbTh = thead.querySelector<HTMLElement>("th[data-col='_cb']");
251
+ if (cbTh) widths.set("_cb", cbTh.offsetWidth);
252
+ setColWidths(widths);
253
+ };
254
+ measure();
255
+ const obs = new ResizeObserver(measure);
256
+ obs.observe(thead);
257
+ return () => obs.disconnect();
258
+ }, [tableData?.columns, pinnedCols]); // re-measure when columns or pins change
259
+
260
+ // Compute sticky left offsets from measured widths
261
+ const pinnedColOffsets = useMemo(() => {
262
+ const offsets = new Map<string, number>();
263
+ let left = colWidths.get("_cb") ?? (pkCol ? 40 : 0);
264
+ for (const col of orderedCols) {
265
+ if (!pinnedCols.has(col)) break;
266
+ offsets.set(col, left);
267
+ left += colWidths.get(col) ?? 100;
268
+ }
269
+ return offsets;
270
+ }, [orderedCols, pinnedCols, pkCol, colWidths]);
271
+
272
+ // Separate pinned rows to render them sticky at top
273
+ const pinnedRowData = useMemo(() =>
274
+ Array.from(pinnedRows).sort((a, b) => a - b).map((i) => ({ idx: i, row: filteredRows[i]! })).filter((r) => r.row),
275
+ [pinnedRows, filteredRows]);
276
+
277
+ // Measure pinned row heights from DOM for accurate sticky offsets
278
+ const pinnedRowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
279
+ const [pinnedRowHeights, setPinnedRowHeights] = useState<Map<number, number>>(new Map());
280
+
281
+ const setPinnedRowRef = useCallback((idx: number, el: HTMLTableRowElement | null) => {
282
+ if (el) pinnedRowRefs.current.set(idx, el);
283
+ else pinnedRowRefs.current.delete(idx);
284
+ }, []);
285
+
286
+ // Measure pinned rows after render
287
+ useEffect(() => {
288
+ if (pinnedRowData.length === 0) {
289
+ if (pinnedRowHeights.size > 0) setPinnedRowHeights(new Map());
290
+ return;
291
+ }
292
+ // Use rAF to measure after browser layout
293
+ const id = requestAnimationFrame(() => {
294
+ const heights = new Map<number, number>();
295
+ for (const { idx } of pinnedRowData) {
296
+ const el = pinnedRowRefs.current.get(idx);
297
+ if (el) heights.set(idx, el.offsetHeight);
298
+ }
299
+ setPinnedRowHeights(heights);
300
+ });
301
+ return () => cancelAnimationFrame(id);
302
+ }, [pinnedRowData, tableData]); // eslint-disable-line react-hooks/exhaustive-deps
303
+
304
+ // Compute cumulative sticky top for each pinned row
305
+ const pinnedRowTops = useMemo(() => {
306
+ const tops = new Map<number, number>();
307
+ let cumTop = headerHeight;
308
+ for (const { idx } of pinnedRowData) {
309
+ tops.set(idx, cumTop);
310
+ cumTop += pinnedRowHeights.get(idx) ?? 28; // fallback 28 for first render
311
+ }
312
+ return tops;
313
+ }, [headerHeight, pinnedRowData, pinnedRowHeights]);
314
+
315
+ return (
316
+ <div ref={containerRef} tabIndex={0} className="flex flex-col h-full overflow-hidden outline-none">
317
+ {/* Search + bulk actions toolbar */}
318
+ <div className="flex items-center gap-2 px-2 py-1 border-b border-border bg-background shrink-0">
319
+ <div className="flex items-center gap-1 flex-1">
320
+ <Search className="size-3 text-muted-foreground" />
321
+ <input type="text" value={globalFilter} onChange={(e) => setGlobalFilter(e.target.value)}
322
+ placeholder="Search current page…"
323
+ className="flex-1 text-xs bg-transparent outline-none text-foreground placeholder:text-muted-foreground" />
324
+ {globalFilter && (
325
+ <button type="button" onClick={() => setGlobalFilter("")} className="text-muted-foreground hover:text-foreground">
326
+ <X className="size-3" />
327
+ </button>
328
+ )}
329
+ </div>
330
+
331
+ {hasSelection && (
332
+ <div className="flex items-center gap-1.5 text-xs">
333
+ <span className="text-muted-foreground">{selectedRows.size} selected</span>
334
+ {onBulkDelete && pkCol && (
335
+ confirmBulkDelete ? (
336
+ <span className="flex items-center gap-1">
337
+ <button type="button" onClick={handleBulkDelete} className="text-destructive text-[10px] font-medium hover:underline">
338
+ Delete {selectedRows.size}?
339
+ </button>
340
+ <button type="button" onClick={() => setConfirmBulkDelete(false)} className="text-muted-foreground text-[10px] hover:underline">Cancel</button>
341
+ </span>
342
+ ) : (
343
+ <button type="button" onClick={() => setConfirmBulkDelete(true)} className="p-0.5 text-muted-foreground hover:text-destructive">
344
+ <Trash2 className="size-3" />
345
+ </button>
346
+ )
347
+ )}
348
+ <ExportButton
349
+ columns={tableData.columns}
350
+ rows={Array.from(selectedRows).map((i) => tableData.rows[i]!).filter(Boolean)}
351
+ filename={`${connectionName ?? "db"}-selected`}
352
+ />
353
+ </div>
354
+ )}
355
+
356
+ {onInsertRow && (
357
+ <button type="button" onClick={() => { setInsertMode(true); setInsertValues({}); setInsertError(null); }}
358
+ className="p-0.5 rounded text-muted-foreground hover:text-primary transition-colors" title="Insert row">
359
+ <Plus className="size-3.5" />
360
+ </button>
361
+ )}
362
+ </div>
363
+
364
+ {/* Insert row form */}
365
+ {insertMode && (
366
+ <div className="px-2 py-1.5 border-b border-border bg-muted/30 text-xs space-y-1">
367
+ <div className="flex flex-wrap gap-1.5">
368
+ {schema.filter((c) => !c.pk).map((col) => (
369
+ <div key={col.name} className="flex items-center gap-1">
370
+ <label className="text-muted-foreground text-[10px] w-16 truncate" title={col.name}>{col.name}</label>
371
+ <input value={insertValues[col.name] ?? ""}
372
+ onChange={(e) => setInsertValues((prev) => ({ ...prev, [col.name]: e.target.value }))}
373
+ placeholder={col.defaultValue ?? (col.nullable ? "NULL" : "")}
374
+ className="h-5 w-24 text-[11px] px-1 rounded border border-border bg-background outline-none focus:border-primary" />
375
+ </div>
376
+ ))}
377
+ </div>
378
+ <div className="flex items-center gap-2">
379
+ <button type="button" onClick={handleInsertSave} className="text-[10px] px-2 py-0.5 rounded bg-primary text-primary-foreground hover:bg-primary/90">Save</button>
380
+ <button type="button" onClick={() => setInsertMode(false)} className="text-[10px] text-muted-foreground hover:underline">Cancel</button>
381
+ {insertError && <span className="text-[10px] text-destructive">{insertError}</span>}
382
+ </div>
383
+ </div>
384
+ )}
385
+
386
+ {/* Table */}
387
+ <div className="flex-1 overflow-auto">
388
+ <table className="w-full text-xs" style={{ borderCollapse: "separate", borderSpacing: 0 }}>
389
+ <thead ref={theadRef} className="sticky top-0 z-20 bg-muted">
390
+ <tr>
391
+ {pkCol && (
392
+ <th data-col="_cb" className="px-2 py-1.5 text-left font-medium text-muted-foreground border-b border-border w-10 bg-muted"
393
+ style={{ position: "sticky", left: 0, zIndex: 30 }}>
394
+ <input type="checkbox" checked={allSelected} onChange={toggleAllRows} className="size-3 accent-primary" />
395
+ </th>
396
+ )}
397
+ {orderedCols.map((col) => {
398
+ const isPk = schema.find((c) => c.name === col)?.pk;
399
+ const isSorted = orderBy === col;
400
+ const isPinned = pinnedCols.has(col);
401
+ const hasFilter = !!colFilters[col];
402
+ const isFilterOpen = filterOpenCol === col;
403
+ const stickyLeft = pinnedColOffsets.get(col);
404
+ return (
405
+ <th key={col} data-col={col}
406
+ className={`group/th px-2 py-1.5 text-left font-medium text-muted-foreground border-b border-border whitespace-nowrap bg-muted ${isPinned ? "border-r border-r-primary/20" : ""}`}
407
+ style={stickyLeft != null ? { position: "sticky", left: stickyLeft, zIndex: 25 } : undefined}>
408
+ <div className="flex items-center gap-0.5">
409
+ <button type="button" onClick={() => onToggleSort(col)}
410
+ className={`flex items-center gap-0.5 ${isPk ? "font-bold" : ""} hover:text-foreground transition-colors`}>
411
+ {col}
412
+ {isSorted && orderDir === "ASC" && <ChevronUp className="size-3" />}
413
+ {isSorted && orderDir === "DESC" && <ChevronDown className="size-3" />}
414
+ </button>
415
+ {/* Filter button */}
416
+ {onColumnFilter && (
417
+ <button type="button" title="Filter column"
418
+ onClick={() => setFilterOpenCol(isFilterOpen ? null : col)}
419
+ className={`p-0.5 rounded transition-colors ${hasFilter || isFilterOpen ? "text-primary" : "text-muted-foreground/40 md:opacity-0 md:group-hover/th:opacity-100 hover:text-foreground"}`}>
420
+ <Filter className="size-2.5" />
421
+ </button>
422
+ )}
423
+ {/* Pin column button */}
424
+ <button type="button" title={isPinned ? "Unpin column" : "Pin column"}
425
+ onClick={() => togglePinCol(col)}
426
+ className={`p-0.5 rounded transition-colors ${isPinned ? "text-primary" : "text-muted-foreground/40 md:opacity-0 md:group-hover/th:opacity-100 hover:text-foreground"}`}>
427
+ {isPinned ? <PinOff className="size-2.5" /> : <Pin className="size-2.5" />}
428
+ </button>
429
+ </div>
430
+ {/* Inline filter input */}
431
+ {isFilterOpen && (
432
+ <div className="mt-1 flex items-center gap-1">
433
+ <input autoFocus type="text" value={colFilters[col] ?? ""} placeholder="ILIKE filter…"
434
+ onChange={(e) => updateColFilter(col, e.target.value)}
435
+ onKeyDown={(e) => { if (e.key === "Escape") setFilterOpenCol(null); }}
436
+ className="w-full h-5 text-[10px] px-1 rounded border border-border bg-background outline-none focus:border-primary" />
437
+ {hasFilter && (
438
+ <button type="button" onClick={() => updateColFilter(col, "")} className="text-muted-foreground hover:text-foreground">
439
+ <X className="size-2.5" />
440
+ </button>
441
+ )}
442
+ </div>
443
+ )}
444
+ </th>
445
+ );
446
+ })}
447
+ {onRowDelete && pkCol && <th className="px-2 py-1.5 border-b border-border w-14 bg-muted" />}
448
+ </tr>
449
+ </thead>
450
+ <tbody>
451
+ {/* Pinned rows — sticky at top below header */}
452
+ {pinnedRowData.map(({ idx, row }) => (
453
+ <DataRow key={`pin-${idx}`} row={row} rowIdx={idx} columns={orderedCols}
454
+ selected={selectedRows.has(idx)} onToggleSelect={toggleRowSelection}
455
+ pkCol={pkCol} editingCell={editingCell} editValue={editValue}
456
+ onStartEdit={startEdit} onCommitEdit={commitEdit} onCancelEdit={cancelEdit}
457
+ onSetEditValue={setEditValue} showDelete={!!onRowDelete}
458
+ confirmingDelete={confirmDeleteIdx === idx}
459
+ onDelete={handleDelete} onConfirmDelete={setConfirmDeleteIdx}
460
+ onViewCell={setViewerCell}
461
+ pinned onTogglePin={togglePinRow}
462
+ pinnedCols={pinnedCols} pinnedColOffsets={pinnedColOffsets}
463
+ stickyTop={pinnedRowTops.get(idx) ?? headerHeight}
464
+ trRef={(el) => setPinnedRowRef(idx, el)} />
465
+ ))}
466
+ {/* Normal rows */}
467
+ {filteredRows.map((row, rowIdx) => {
468
+ if (pinnedRows.has(rowIdx)) return null; // skip pinned — rendered above
469
+ return (
470
+ <DataRow key={rowIdx} row={row} rowIdx={rowIdx} columns={orderedCols}
471
+ selected={selectedRows.has(rowIdx)} onToggleSelect={toggleRowSelection}
472
+ pkCol={pkCol} editingCell={editingCell} editValue={editValue}
473
+ onStartEdit={startEdit} onCommitEdit={commitEdit} onCancelEdit={cancelEdit}
474
+ onSetEditValue={setEditValue} showDelete={!!onRowDelete}
475
+ confirmingDelete={confirmDeleteIdx === rowIdx}
476
+ onDelete={handleDelete} onConfirmDelete={setConfirmDeleteIdx}
477
+ onViewCell={setViewerCell}
478
+ pinned={false} onTogglePin={togglePinRow}
479
+ pinnedCols={pinnedCols} pinnedColOffsets={pinnedColOffsets} />
480
+ );
481
+ })}
482
+ {filteredRows.length === 0 && (
483
+ <tr><td colSpan={orderedCols.length + 2} className="px-2 py-8 text-center text-muted-foreground">No data</td></tr>
484
+ )}
485
+ </tbody>
486
+ </table>
487
+ </div>
488
+
489
+ {/* Footer: row count + pagination */}
490
+ <div className="flex items-center justify-between px-3 py-1.5 border-t border-border bg-background shrink-0 text-xs text-muted-foreground">
491
+ <span>{tableData.total.toLocaleString()} rows</span>
492
+ <div className="flex items-center gap-2">
493
+ <button type="button" disabled={page <= 1} onClick={() => onPageChange(page - 1)} className="p-0.5 rounded hover:bg-muted disabled:opacity-30">
494
+ <ChevronLeft className="size-3.5" />
495
+ </button>
496
+ <span>{page} / {totalPages}</span>
497
+ <button type="button" disabled={page >= totalPages} onClick={() => onPageChange(page + 1)} className="p-0.5 rounded hover:bg-muted disabled:opacity-30">
498
+ <ChevronRight className="size-3.5" />
499
+ </button>
500
+ </div>
501
+ </div>
502
+
503
+ {viewerCell && (
504
+ <CellViewerDialog value={viewerCell.value} column={viewerCell.col}
505
+ open={true} onOpenChange={(v) => { if (!v) setViewerCell(null); }} />
506
+ )}
507
+ </div>
508
+ );
509
+ }
510
+
511
+ /** Format cell value — JSON-stringify objects/arrays, otherwise String() */
512
+ function formatCellValue(val: unknown): string {
513
+ if (val == null) return "NULL";
514
+ if (typeof val === "object") return JSON.stringify(val);
515
+ return String(val);
516
+ }
517
+
518
+ /** Large data threshold (200 bytes) or structured data (json object/array, xml-like) */
519
+ const LARGE_THRESHOLD = 200;
520
+ function needsViewer(val: unknown): boolean {
521
+ if (val == null) return false;
522
+ if (typeof val === "object") return true; // json/jsonb column
523
+ const s = String(val);
524
+ if (s.length >= LARGE_THRESHOLD) return true;
525
+ // Detect JSON string or XML string
526
+ const trimmed = s.trimStart();
527
+ if ((trimmed[0] === "{" || trimmed[0] === "[") && (trimmed.endsWith("}") || trimmed.endsWith("]"))) return true;
528
+ if (trimmed.startsWith("<?xml") || trimmed.startsWith("<") && trimmed.endsWith(">")) return true;
529
+ return false;
530
+ }
531
+
532
+ /** Try to detect format and beautify */
533
+ function beautify(text: string): string {
534
+ const trimmed = text.trimStart();
535
+ // JSON
536
+ if (trimmed[0] === "{" || trimmed[0] === "[") {
537
+ try { return JSON.stringify(JSON.parse(trimmed), null, 2); } catch { /* not valid json */ }
538
+ }
539
+ // XML — simple indent
540
+ if (trimmed.startsWith("<")) {
541
+ try {
542
+ let indent = 0;
543
+ return trimmed.replace(/(>)(<)(\/*)/g, "$1\n$2$3")
544
+ .split("\n")
545
+ .map((line) => {
546
+ const l = line.trim();
547
+ if (l.startsWith("</")) indent = Math.max(0, indent - 1);
548
+ const padded = " ".repeat(indent) + l;
549
+ if (l.startsWith("<") && !l.startsWith("</") && !l.endsWith("/>") && !l.includes("</")) indent++;
550
+ return padded;
551
+ })
552
+ .join("\n");
553
+ } catch { /* not valid xml */ }
554
+ }
555
+ return text;
556
+ }
557
+
558
+ /** Detect language from content for syntax highlighting */
559
+ function detectLang(text: string): string {
560
+ const t = text.trimStart();
561
+ if (t[0] === "{" || t[0] === "[") {
562
+ try { JSON.parse(t); return "json"; } catch { /* not json */ }
563
+ }
564
+ if (t.startsWith("<?xml") || (t.startsWith("<") && /<\/\w+>/.test(t))) return "xml";
565
+ if (t.startsWith("---") || /^\w+:\s/m.test(t)) return "yaml";
566
+ return "plaintext";
567
+ }
568
+
569
+ /** Dialog for viewing large / structured cell data */
570
+ function CellViewerDialog({ value, column, open, onOpenChange }: {
571
+ value: string; column: string; open: boolean; onOpenChange: (v: boolean) => void;
572
+ }) {
573
+ const [text, setText] = useState(value);
574
+ const [beautified, setBeautified] = useState(false);
575
+ const monacoTheme = useMonacoTheme();
576
+ const lang = useMemo(() => detectLang(text), [text]);
577
+ const canBeautify = lang === "json" || lang === "xml";
578
+
579
+ useEffect(() => { setText(value); setBeautified(false); }, [value]);
580
+
581
+ const handleBeautify = () => {
582
+ if (beautified) { setText(value); setBeautified(false); }
583
+ else { setText(beautify(value)); setBeautified(true); }
584
+ };
585
+
586
+ return (
587
+ <Dialog open={open} onOpenChange={onOpenChange}>
588
+ <DialogContent className="flex flex-col p-4 gap-3
589
+ max-md:fixed max-md:bottom-0 max-md:left-0 max-md:right-0 max-md:top-auto max-md:translate-x-0 max-md:translate-y-0 max-md:h-[75dvh] max-md:max-w-full max-md:rounded-b-none max-md:rounded-t-xl
590
+ md:max-w-4xl md:h-[80vh]">
591
+ <DialogHeader>
592
+ <DialogTitle className="text-sm font-medium">{column}</DialogTitle>
593
+ </DialogHeader>
594
+ <div className="flex items-center gap-2 shrink-0">
595
+ {canBeautify && (
596
+ <button type="button" onClick={handleBeautify}
597
+ className={`text-[11px] px-2 py-0.5 rounded border transition-colors ${beautified ? "bg-primary text-primary-foreground border-primary" : "border-border text-muted-foreground hover:text-foreground hover:border-foreground/30"}`}>
598
+ {beautified ? "Raw" : "Beautify"}
599
+ </button>
600
+ )}
601
+ <button type="button" onClick={() => navigator.clipboard.writeText(text)}
602
+ className="text-[11px] px-2 py-0.5 rounded border border-border text-muted-foreground hover:text-foreground hover:border-foreground/30 transition-colors">
603
+ Copy
604
+ </button>
605
+ <span className="text-[10px] text-muted-foreground ml-auto">{lang} · {text.length.toLocaleString()} chars</span>
606
+ </div>
607
+ <div className="flex-1 min-h-0 rounded border border-border overflow-hidden">
608
+ <Editor
609
+ value={text}
610
+ language={lang}
611
+ theme={monacoTheme}
612
+ options={{
613
+ readOnly: true, minimap: { enabled: false }, lineNumbers: "on",
614
+ scrollBeyondLastLine: false, wordWrap: "on", fontSize: 12,
615
+ renderLineHighlight: "none", overviewRulerLanes: 0,
616
+ scrollbar: { verticalScrollbarSize: 8, horizontalScrollbarSize: 8 },
617
+ padding: { top: 8, bottom: 8 },
618
+ }}
619
+ />
620
+ </div>
621
+ </DialogContent>
622
+ </Dialog>
623
+ );
624
+ }
625
+
626
+ /** Memoized row — only re-renders when its own props change */
627
+ const DataRow = memo(function DataRow({ row, rowIdx, columns, selected, onToggleSelect, pkCol,
628
+ editingCell, editValue, onStartEdit, onCommitEdit, onCancelEdit, onSetEditValue,
629
+ showDelete, confirmingDelete, onDelete, onConfirmDelete, onViewCell,
630
+ pinned, onTogglePin, pinnedCols, pinnedColOffsets, stickyTop, trRef,
631
+ }: {
632
+ row: Record<string, unknown>; rowIdx: number; columns: string[];
633
+ selected: boolean; onToggleSelect: (i: number) => void;
634
+ pkCol: string | null;
635
+ editingCell: { rowIdx: number; col: string } | null; editValue: string;
636
+ onStartEdit: (i: number, col: string, val: unknown) => void;
637
+ onCommitEdit: () => void; onCancelEdit: () => void;
638
+ onSetEditValue: (v: string) => void;
639
+ showDelete: boolean; confirmingDelete: boolean;
640
+ onDelete: (i: number) => void; onConfirmDelete: (i: number | null) => void;
641
+ onViewCell: (cell: { col: string; value: string }) => void;
642
+ pinned: boolean; onTogglePin: (i: number) => void;
643
+ pinnedCols: Set<string>; pinnedColOffsets: Map<string, number>;
644
+ stickyTop?: number;
645
+ trRef?: (el: HTMLTableRowElement | null) => void;
646
+ }) {
647
+ // Opaque bg required for sticky cells to cover content underneath
648
+ const cellBg = pinned ? "bg-muted" : selected ? "bg-primary/5" : "bg-background";
649
+ return (
650
+ <tr ref={trRef} className={`group ${pinned ? "" : "hover:bg-muted/30"}`}
651
+ style={pinned ? { position: "sticky", top: stickyTop, zIndex: 15 } : undefined}>
652
+ {pkCol && (
653
+ <td className={`px-1 py-1 border-b border-border/50 ${cellBg}`}
654
+ style={{ position: "sticky", left: 0, zIndex: 12 }}>
655
+ <span className="flex items-center gap-0.5">
656
+ <input type="checkbox" checked={selected} onChange={() => onToggleSelect(rowIdx)} className="size-3 accent-primary" />
657
+ <button type="button" title={pinned ? "Unpin row" : "Pin row"} onClick={() => onTogglePin(rowIdx)}
658
+ className={`p-0.5 rounded transition-colors ${pinned ? "text-primary" : "text-muted-foreground/30 md:opacity-0 md:group-hover:opacity-100 hover:text-foreground"}`}>
659
+ {pinned ? <PinOff className="size-2.5" /> : <Pin className="size-2.5" />}
660
+ </button>
661
+ </span>
662
+ </td>
663
+ )}
664
+ {columns.map((col) => {
665
+ const isEditing = editingCell?.rowIdx === rowIdx && editingCell?.col === col;
666
+ const val = row[col];
667
+ const showEye = !isEditing && needsViewer(val);
668
+ const isColPinned = pinnedCols.has(col);
669
+ const stickyLeft = pinnedColOffsets.get(col);
670
+ const needsBg = isColPinned || pinned;
671
+ return (
672
+ <td key={col}
673
+ className={`px-2 py-1 max-w-[300px] border-b border-border/50 ${isColPinned ? "border-r border-r-primary/20" : ""} ${needsBg ? cellBg : ""}`}
674
+ style={stickyLeft != null ? { position: "sticky", left: stickyLeft, zIndex: 10 } : undefined}>
675
+ {isEditing ? (
676
+ <input autoFocus className="w-full bg-transparent border border-primary/50 rounded px-1 py-0 text-xs outline-none"
677
+ value={editValue} onChange={(e) => onSetEditValue(e.target.value)}
678
+ onBlur={onCommitEdit} onKeyDown={(e) => { if (e.key === "Enter") onCommitEdit(); if (e.key === "Escape") onCancelEdit(); }} />
679
+ ) : (
680
+ <span className="flex items-center gap-0.5">
681
+ <span className={`cursor-pointer truncate flex-1 ${val == null ? "text-muted-foreground/40 italic" : ""}`}
682
+ onDoubleClick={() => pkCol && onStartEdit(rowIdx, col, val)} title={formatCellValue(val)}>
683
+ {formatCellValue(val)}
684
+ </span>
685
+ {showEye && (
686
+ <button type="button" title="View full content"
687
+ onClick={() => onViewCell({ col, value: formatCellValue(val) })}
688
+ className="shrink-0 p-0.5 rounded text-muted-foreground/50 hover:text-foreground transition-colors">
689
+ <Eye className="size-3" />
690
+ </button>
691
+ )}
692
+ </span>
693
+ )}
694
+ </td>
695
+ );
696
+ })}
697
+ {showDelete && pkCol && (
698
+ <td className={`px-2 py-1 border-b border-border/50 ${pinned ? cellBg : ""}`}>
699
+ {confirmingDelete ? (
700
+ <span className="flex items-center gap-1 whitespace-nowrap">
701
+ <button type="button" onClick={() => onDelete(rowIdx)} className="text-destructive text-[10px] font-medium hover:underline">Confirm</button>
702
+ <button type="button" onClick={() => onConfirmDelete(null)} className="text-muted-foreground text-[10px] hover:underline">Cancel</button>
703
+ </span>
704
+ ) : (
705
+ <button type="button" onClick={() => onConfirmDelete(rowIdx)}
706
+ className="p-0.5 rounded md:opacity-0 md:group-hover:opacity-100 hover:bg-destructive/10 hover:text-destructive transition-opacity" title="Delete row">
707
+ <Trash2 className="size-3" />
708
+ </button>
709
+ )}
710
+ </td>
711
+ )}
712
+ </tr>
713
+ );
714
+ });