@hienlh/ppm 0.13.8 → 0.13.10

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 (114) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/assets/skills/ppm/SKILL.md +1 -1
  3. package/assets/skills/ppm/references/http-api.md +1 -1
  4. package/dist/web/assets/ai-settings-section-ysK_Eixc.js +1 -0
  5. package/dist/web/assets/architecture-PBZL5I3N-By4Nv3Gj.js +1 -0
  6. package/dist/web/assets/{audio-preview-DQbX8gfL.js → audio-preview-DISP-2AE.js} +1 -1
  7. package/dist/web/assets/{chat-tab-BJQT9kie.js → chat-tab-lp_mVSG-.js} +8 -8
  8. package/dist/web/assets/code-editor-BofKrbM8.js +8 -0
  9. package/dist/web/assets/{conflict-editor-BKwJLX0D.js → conflict-editor-kaAZUFD5.js} +1 -1
  10. package/dist/web/assets/csv-parser-Dly5nqE1.js +6 -0
  11. package/dist/web/assets/{csv-preview-D5lmgVEy.js → csv-preview-7TsYBQI6.js} +3 -3
  12. package/dist/web/assets/data-grid-overlay-editor-BjjuE4-G.js +1 -0
  13. package/dist/web/assets/data-grid-types-BTQHYBUh.js +1 -0
  14. package/dist/web/assets/database-DOWH9-Vv.js +1 -0
  15. package/dist/web/assets/database-viewer-P38Vxzkx.js +1 -0
  16. package/dist/web/assets/{diff-viewer-SAtaBwNI.js → diff-viewer-DlJfbgNJ.js} +1 -1
  17. package/dist/web/assets/dist-0kPgRaVx.js +1 -0
  18. package/dist/web/assets/{esm-nXReYVnB.js → esm-zjerHxpO.js} +1 -1
  19. package/dist/web/assets/{extension-webview-PiV4bKJ1.js → extension-webview-5s2MUx38.js} +1 -1
  20. package/dist/web/assets/gitGraph-HDMCJU4V-BLXEKVf1.js +1 -0
  21. package/dist/web/assets/glide-data-grid-D5D1N3L7.js +136 -0
  22. package/dist/web/assets/glide-data-grid-nthEL3fk.css +1 -0
  23. package/dist/web/assets/{image-preview-CbFFD9BS.js → image-preview-C9osjEPa.js} +1 -1
  24. package/dist/web/assets/index-COOnLKGB.css +2 -0
  25. package/dist/web/assets/index-CpcqiQOx.js +27 -0
  26. package/dist/web/assets/info-3K5VOQVL-CEkPcChg.js +1 -0
  27. package/dist/web/assets/{input-BMvRUOr7.js → input-ozrR2DAV.js} +1 -1
  28. package/dist/web/assets/keybindings-store-COxqSoML.js +1 -0
  29. package/dist/web/assets/{markdown-renderer-CHWA0KAo.js → markdown-renderer-k3EA9XmF.js} +3 -3
  30. package/dist/web/assets/number-overlay-editor-BoRxunFN.js +9 -0
  31. package/dist/web/assets/packet-RMMSAZCW-DECxYTOi.js +1 -0
  32. package/dist/web/assets/{pdf-preview-DQMdjqa2.js → pdf-preview-DvgyxJX7.js} +1 -1
  33. package/dist/web/assets/pie-UPGHQEXC-cjpNfVG5.js +1 -0
  34. package/dist/web/assets/{port-forwarding-tab-9BpNC9_7.js → port-forwarding-tab-CUkU6wac.js} +1 -1
  35. package/dist/web/assets/{postgres-viewer-Bm5T51n6.js → postgres-viewer-C1w0tqQw.js} +3 -3
  36. package/dist/web/assets/radar-KQ55EAFF-Dnpi068b.js +1 -0
  37. package/dist/web/assets/{settings-store-BHBb62gq.js → settings-store-B-OmHo3J.js} +1 -1
  38. package/dist/web/assets/settings-tab-D0zKyVwg.js +1 -0
  39. package/dist/web/assets/sql-query-editor-46hLU7MI.js +3 -0
  40. package/dist/web/assets/sqlite-viewer-DrLi8P6y.js +1 -0
  41. package/dist/web/assets/terminal-tab-DqA3fEoQ.js +1 -0
  42. package/dist/web/assets/treemap-KZPCXAKY-DRyb1eiw.js +1 -0
  43. package/dist/web/assets/{use-monaco-theme-CP-vyTF8.js → use-monaco-theme-DgzxiZS5.js} +1 -1
  44. package/dist/web/assets/{vendor-mermaid-CMiurk2b.js → vendor-mermaid-CCmA_6Y0.js} +3 -3
  45. package/dist/web/assets/{video-preview-BLI_RruT.js → video-preview-BU7tibc4.js} +1 -1
  46. package/dist/web/assets/x-CG-_0yIW.js +1 -0
  47. package/dist/web/index.html +13 -14
  48. package/dist/web/sw.js +1 -1
  49. package/package.json +2 -1
  50. package/src/web/components/database/data-grid.tsx +18 -2
  51. package/src/web/components/database/database-viewer.tsx +19 -8
  52. package/src/web/components/database/export-button.tsx +38 -18
  53. package/src/web/components/database/glide-column-search.tsx +81 -0
  54. package/src/web/components/database/glide-context-menu.tsx +95 -0
  55. package/src/web/components/database/glide-data-grid.tsx +207 -0
  56. package/src/web/components/database/glide-data-preview-panel.tsx +113 -0
  57. package/src/web/components/database/glide-grid-pagination.tsx +34 -0
  58. package/src/web/components/database/glide-grid-theme.ts +82 -0
  59. package/src/web/components/database/glide-grid-toolbar.tsx +105 -0
  60. package/src/web/components/database/glide-grid-types.ts +81 -0
  61. package/src/web/components/database/glide-header-menu.tsx +111 -0
  62. package/src/web/components/database/glide-save-bar.tsx +33 -0
  63. package/src/web/components/database/sql-query-editor.tsx +14 -4
  64. package/src/web/components/database/use-database.ts +10 -2
  65. package/src/web/components/database/use-glide-cell-content.ts +159 -0
  66. package/src/web/components/database/use-glide-columns.ts +69 -0
  67. package/src/web/components/database/use-glide-grid-actions.ts +164 -0
  68. package/src/web/components/database/use-glide-pending-edits.ts +72 -0
  69. package/src/web/components/database/use-glide-row-pinning.ts +35 -0
  70. package/src/web/components/database/use-glide-selection.ts +48 -0
  71. package/src/web/components/editor/code-editor.tsx +126 -7
  72. package/src/web/components/layout/editor-panel.tsx +2 -2
  73. package/src/web/components/sqlite/sqlite-viewer.tsx +21 -12
  74. package/src/web/components/sqlite/use-sqlite.ts +1 -1
  75. package/src/web/hooks/use-terminal.ts +1 -1
  76. package/src/web/index.html +1 -0
  77. package/test.sql +1 -0
  78. package/dist/web/assets/ai-settings-section-CHgpQ_OP.js +0 -1
  79. package/dist/web/assets/architecture-PBZL5I3N-WMbLpD5Y.js +0 -1
  80. package/dist/web/assets/code-editor-CeKTvfyz.js +0 -8
  81. package/dist/web/assets/csv-parser-DO0dz4x_.js +0 -6
  82. package/dist/web/assets/database-DCT0OjgQ.js +0 -1
  83. package/dist/web/assets/database-viewer-DixWWvjx.js +0 -5
  84. package/dist/web/assets/gitGraph-HDMCJU4V-BdPTuzO3.js +0 -1
  85. package/dist/web/assets/index-C1RBJe0a.css +0 -2
  86. package/dist/web/assets/index-ZFyltHwi.js +0 -27
  87. package/dist/web/assets/info-3K5VOQVL-MHX_1JfR.js +0 -1
  88. package/dist/web/assets/keybindings-store-D0C-Pq2o.js +0 -1
  89. package/dist/web/assets/packet-RMMSAZCW-CreFbf9A.js +0 -1
  90. package/dist/web/assets/pie-UPGHQEXC-CnaHXUh8.js +0 -1
  91. package/dist/web/assets/plus-51UQ45rf.js +0 -1
  92. package/dist/web/assets/radar-KQ55EAFF-UxsdRHvt.js +0 -1
  93. package/dist/web/assets/settings-tab-BUstSDLR.js +0 -1
  94. package/dist/web/assets/sql-completion-provider-tCzZfqWs.js +0 -1
  95. package/dist/web/assets/sql-query-editor-CMQpaOjA.js +0 -3
  96. package/dist/web/assets/sqlite-viewer-C7rhO4bn.js +0 -1
  97. package/dist/web/assets/terminal-tab-Xtj6RN0d.js +0 -1
  98. package/dist/web/assets/trash-2-CJYoLw7Q.js +0 -1
  99. package/dist/web/assets/treemap-KZPCXAKY-CBVPi4NV.js +0 -1
  100. package/dist/web/assets/x-BtqbfkR7.js +0 -1
  101. /package/dist/web/assets/{arrow-up-Dtrfv490.js → arrow-up-Rcw6_KKu.js} +0 -0
  102. /package/dist/web/assets/{chevron-right-BzAdxJRG.js → chevron-right-DnHIvvcy.js} +0 -0
  103. /package/dist/web/assets/{code-CuravVys.js → code-DGBecc50.js} +0 -0
  104. /package/dist/web/assets/{dist-D7KGU7Vl.js → dist-CaKCIxem.js} +0 -0
  105. /package/dist/web/assets/{dist-CGvx1c8C.js → dist-DGSkE2Ml.js} +0 -0
  106. /package/dist/web/assets/{katex-BFE6i_OH.js → katex-BuytEdO1.js} +0 -0
  107. /package/dist/web/assets/{lib-D_kRA9p6.js → lib-DQHnkzGy.js} +0 -0
  108. /package/dist/web/assets/{refresh-cw-CSFrDtiu.js → refresh-cw-LlbZDJpO.js} +0 -0
  109. /package/dist/web/assets/{scroll-area-BEllam7_.js → scroll-area-7H-Q_k8c.js} +0 -0
  110. /package/dist/web/assets/{sparkles-B0mRBy_j.js → sparkles-fWUT5Vzq.js} +0 -0
  111. /package/dist/web/assets/{table-Dq575bPF.js → table-tf7pRkME.js} +0 -0
  112. /package/dist/web/assets/{text-wrap-Cn6BNQfq.js → text-wrap-BV-R4Vvy.js} +0 -0
  113. /package/dist/web/assets/{use-blob-url-Hn6n1730.js → use-blob-url-e9uTXjv5.js} +0 -0
  114. /package/dist/web/assets/{vendor-xterm-u3AZMvTx.js → vendor-xterm-CU2c3f0A.js} +0 -0
@@ -0,0 +1,111 @@
1
+ import { useState, useEffect, useRef } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { Filter, Pin, PinOff, ArrowUp, ArrowDown, X } from "lucide-react";
4
+
5
+ interface HeaderMenuProps {
6
+ colName: string;
7
+ bounds: { x: number; y: number; width: number; height: number };
8
+ isPinned: boolean;
9
+ filterValue: string;
10
+ sortState: "asc" | "desc" | null;
11
+ onFilter: (value: string) => void;
12
+ onSort: () => void;
13
+ onClearSort?: () => void;
14
+ onTogglePin: () => void;
15
+ onClose: () => void;
16
+ }
17
+
18
+ /**
19
+ * Header column dropdown menu — filter input, sort toggle, pin/unpin.
20
+ * Rendered via React portal into #portal div (required by Glide Data Grid).
21
+ */
22
+ export function GlideHeaderMenu({
23
+ colName, bounds, isPinned, filterValue, sortState,
24
+ onFilter, onSort, onClearSort, onTogglePin, onClose,
25
+ }: HeaderMenuProps) {
26
+ const [localFilter, setLocalFilter] = useState(filterValue);
27
+ const ref = useRef<HTMLDivElement>(null);
28
+ const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
29
+
30
+ // Close on outside click
31
+ useEffect(() => {
32
+ const handler = (e: MouseEvent) => {
33
+ if (ref.current && !ref.current.contains(e.target as Node)) onClose();
34
+ };
35
+ document.addEventListener("mousedown", handler);
36
+ return () => document.removeEventListener("mousedown", handler);
37
+ }, [onClose]);
38
+
39
+ // Close on Escape
40
+ useEffect(() => {
41
+ const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
42
+ document.addEventListener("keydown", handler);
43
+ return () => document.removeEventListener("keydown", handler);
44
+ }, [onClose]);
45
+
46
+ // Debounced filter
47
+ const handleFilterChange = (val: string) => {
48
+ setLocalFilter(val);
49
+ clearTimeout(debounceRef.current);
50
+ debounceRef.current = setTimeout(() => onFilter(val), 300);
51
+ };
52
+
53
+ // Clamp position to viewport
54
+ const menuWidth = 220;
55
+ const left = Math.min(bounds.x, window.innerWidth - menuWidth - 8);
56
+ const top = bounds.y + bounds.height + 2;
57
+
58
+ const portal = document.getElementById("portal");
59
+ if (!portal) return null;
60
+
61
+ return createPortal(
62
+ <div ref={ref} style={{ position: "fixed", left, top, zIndex: 10000 }}
63
+ className="w-[220px] bg-popover border border-border rounded-md shadow-lg text-xs overflow-hidden">
64
+ {/* Column name header */}
65
+ <div className="px-3 py-1.5 border-b border-border text-muted-foreground font-medium truncate">
66
+ {colName}
67
+ </div>
68
+
69
+ {/* Filter input */}
70
+ <div className="px-2 py-1.5 border-b border-border">
71
+ <div className="flex items-center gap-1">
72
+ <Filter className="size-3 text-muted-foreground shrink-0" />
73
+ <input
74
+ autoFocus type="text" value={localFilter}
75
+ onChange={(e) => handleFilterChange(e.target.value)}
76
+ placeholder="Filter (ILIKE)…"
77
+ className="flex-1 bg-transparent outline-none text-foreground placeholder:text-muted-foreground text-xs"
78
+ />
79
+ {localFilter && (
80
+ <button type="button" onClick={() => handleFilterChange("")}
81
+ className="text-muted-foreground hover:text-foreground">
82
+ <X className="size-3" />
83
+ </button>
84
+ )}
85
+ </div>
86
+ </div>
87
+
88
+ {/* Actions */}
89
+ <div className="py-1">
90
+ <button type="button" onClick={() => { onSort(); onClose(); }}
91
+ className="w-full text-left px-3 py-1.5 hover:bg-muted flex items-center gap-2 text-foreground">
92
+ {sortState === "asc" ? <ArrowDown className="size-3" /> : <ArrowUp className="size-3" />}
93
+ {sortState === "asc" ? "Sort Descending" : "Sort Ascending"}
94
+ </button>
95
+ {sortState && onClearSort && (
96
+ <button type="button" onClick={() => { onClearSort(); onClose(); }}
97
+ className="w-full text-left px-3 py-1.5 hover:bg-muted flex items-center gap-2 text-foreground">
98
+ <X className="size-3" />
99
+ Clear Sort
100
+ </button>
101
+ )}
102
+ <button type="button" onClick={() => { onTogglePin(); onClose(); }}
103
+ className="w-full text-left px-3 py-1.5 hover:bg-muted flex items-center gap-2 text-foreground">
104
+ {isPinned ? <PinOff className="size-3" /> : <Pin className="size-3" />}
105
+ {isPinned ? "Unpin Column" : "Pin Column"}
106
+ </button>
107
+ </div>
108
+ </div>,
109
+ portal,
110
+ );
111
+ }
@@ -0,0 +1,33 @@
1
+ import { Save, Undo2 } from "lucide-react";
2
+
3
+ interface SaveBarProps {
4
+ pendingCount: number;
5
+ onSave: () => void;
6
+ onDiscard: () => void;
7
+ }
8
+
9
+ /**
10
+ * Floating save bar shown when there are pending cell edits.
11
+ * Save with click or Mod+Enter. Discard with Escape.
12
+ */
13
+ export function GlideSaveBar({ pendingCount, onSave, onDiscard }: SaveBarProps) {
14
+ if (pendingCount === 0) return null;
15
+
16
+ return (
17
+ <div className="flex items-center gap-2 px-3 py-1.5 border-t border-amber-400/50 bg-amber-50 dark:bg-amber-950/30 shrink-0 text-xs">
18
+ <span className="text-amber-700 dark:text-amber-300 font-medium">
19
+ {pendingCount} pending edit{pendingCount > 1 ? "s" : ""}
20
+ </span>
21
+ <div className="flex-1" />
22
+ <button type="button" onClick={onDiscard}
23
+ className="flex items-center gap-1 px-2 py-0.5 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors">
24
+ <Undo2 className="size-3" /> Discard
25
+ </button>
26
+ <button type="button" onClick={onSave}
27
+ className="flex items-center gap-1 px-2 py-0.5 rounded bg-primary text-primary-foreground hover:bg-primary/90 transition-colors">
28
+ <Save className="size-3" /> Save
29
+ <kbd className="ml-1 text-[9px] opacity-70">⌘↵</kbd>
30
+ </button>
31
+ </div>
32
+ );
33
+ }
@@ -9,6 +9,8 @@ interface SqlQueryEditorProps {
9
9
  loading: boolean;
10
10
  defaultValue?: string;
11
11
  schemaInfo?: SchemaInfo;
12
+ /** Unique key for caching query text in sessionStorage (e.g. connectionId) */
13
+ cacheKey?: string;
12
14
  }
13
15
 
14
16
  /** Find the SQL statement surrounding the cursor line (split by ;) */
@@ -41,8 +43,13 @@ export function getStatementAtCursor(text: string, cursorLine: number): string {
41
43
  }
42
44
 
43
45
  /** Shared Monaco-based SQL query editor (editor only, no results) */
44
- export function SqlQueryEditor({ onExecute, loading, defaultValue = "SELECT * FROM ", schemaInfo }: SqlQueryEditorProps) {
45
- const [query, setQuery] = useState(defaultValue);
46
+ export function SqlQueryEditor({ onExecute, loading, defaultValue = "SELECT * FROM ", schemaInfo, cacheKey }: SqlQueryEditorProps) {
47
+ const storageKey = cacheKey ? `ppm:sql-query:${cacheKey}` : null;
48
+ const [query, setQuery] = useState(() => {
49
+ if (storageKey) { try { return sessionStorage.getItem(storageKey) ?? defaultValue; } catch { /* */ } }
50
+ return defaultValue;
51
+ });
52
+ const userEditedRef = useRef(false);
46
53
  const editorRef = useRef<MonacoType.editor.IStandaloneCodeEditor | null>(null);
47
54
  const monacoRef = useRef<typeof MonacoType | null>(null);
48
55
  const disposableRef = useRef<MonacoType.IDisposable | null>(null);
@@ -88,7 +95,10 @@ export function SqlQueryEditor({ onExecute, loading, defaultValue = "SELECT * FR
88
95
  }
89
96
  }, [schemaInfo]);
90
97
 
91
- useEffect(() => { setQuery(defaultValue); }, [defaultValue]);
98
+ // Sync from defaultValue only if user hasn't manually edited
99
+ useEffect(() => {
100
+ if (!userEditedRef.current) setQuery(defaultValue);
101
+ }, [defaultValue]);
92
102
 
93
103
  return (
94
104
  <div className="h-full overflow-hidden">
@@ -97,7 +107,7 @@ export function SqlQueryEditor({ onExecute, loading, defaultValue = "SELECT * FR
97
107
  language="sql"
98
108
  theme={monacoTheme}
99
109
  value={query}
100
- onChange={(v) => setQuery(v ?? "")}
110
+ onChange={(v) => { const val = v ?? ""; setQuery(val); userEditedRef.current = true; if (storageKey) try { sessionStorage.setItem(storageKey, val); } catch {} }}
101
111
  onMount={handleMount}
102
112
  options={{
103
113
  minimap: { enabled: false },
@@ -2,7 +2,7 @@ import { useState, useCallback } from "react";
2
2
  import { api } from "@/lib/api-client";
3
3
 
4
4
  export interface DbTableInfo { name: string; schema: string; rowCount: number }
5
- export interface DbColumnInfo { name: string; type: string; nullable: boolean; pk: boolean; defaultValue: string | null }
5
+ export interface DbColumnInfo { name: string; type: string; nullable: boolean; pk: boolean; defaultValue: string | null; fk?: { table: string; column: string } | null }
6
6
  export interface DbQueryResult { columns: string[]; rows: Record<string, unknown>[]; rowsAffected: number; changeType: "select" | "modify"; executionTimeMs?: number }
7
7
  interface DbTableData { columns: string[]; rows: Record<string, unknown>[]; total: number; page: number; limit: number }
8
8
 
@@ -146,6 +146,14 @@ export function useDatabase(connectionId: number) {
146
146
  fetchTableData(undefined, undefined, 1, newCol, newDir);
147
147
  }, [orderBy, orderDir, fetchTableData]);
148
148
 
149
+ /** Clear sort entirely */
150
+ const clearSort = useCallback(() => {
151
+ setOrderBy(null);
152
+ setOrderDir("ASC");
153
+ setPageState(1);
154
+ fetchTableData(undefined, undefined, 1, null, "ASC");
155
+ }, [fetchTableData]);
156
+
149
157
  /** Bulk delete rows */
150
158
  const bulkDelete = useCallback(async (pkColumn: string, pkValues: unknown[]) => {
151
159
  if (!selectedTable) return;
@@ -191,7 +199,7 @@ export function useDatabase(connectionId: number) {
191
199
  return {
192
200
  selectedTable, selectedSchema, selectTable, tableData, schema,
193
201
  loading, error, page, setPage: changePage,
194
- orderBy, orderDir, toggleSort,
202
+ orderBy, orderDir, toggleSort, clearSort,
195
203
  queryResult, queryError, queryLoading, executeQuery,
196
204
  updateCell, deleteRow, bulkDelete, insertRow,
197
205
  refreshData: fetchTableData, queryAsTable,
@@ -0,0 +1,159 @@
1
+ import { useCallback, useRef } from "react";
2
+ import { GridCellKind, type GridCell, type EditableGridCell, type Item } from "@glideapps/glide-data-grid";
3
+ import type { GridColumnSchema } from "./glide-grid-types";
4
+ import { formatCellValue } from "./glide-grid-types";
5
+ import type { PendingEdit } from "./use-glide-pending-edits";
6
+
7
+ /** Map DB type string to Glide cell kind */
8
+ function dbTypeToKind(type: string): GridCellKind {
9
+ const t = type.toLowerCase();
10
+ if (/^(int|serial|bigint|smallint|float|double|decimal|numeric|real|money)/.test(t)) {
11
+ return GridCellKind.Number;
12
+ }
13
+ if (/^bool/.test(t)) return GridCellKind.Boolean;
14
+ return GridCellKind.Text;
15
+ }
16
+
17
+ /** Check if a PK column is auto-increment (serial, identity, nextval default) */
18
+ function isAutoIncrement(col: GridColumnSchema): boolean {
19
+ const t = col.type.toLowerCase();
20
+ if (/^(serial|bigserial|smallserial)/.test(t)) return true;
21
+ if (/^(int|bigint|smallint|integer)/.test(t) && col.defaultValue && /nextval|identity|auto_increment/i.test(col.defaultValue)) return true;
22
+ // SQLite: INTEGER PRIMARY KEY is auto-increment by default
23
+ if (t === "integer" && col.pk && !col.defaultValue) return true;
24
+ return false;
25
+ }
26
+
27
+ /** Truncate display string for canvas rendering performance */
28
+ function truncateDisplay(val: string, max = 200): string {
29
+ return val.length > max ? val.slice(0, max) + "…" : val;
30
+ }
31
+
32
+ /** Amber background for cells with pending unsaved edits */
33
+ const PENDING_THEME = { bgCell: "rgba(251, 191, 36, 0.15)" };
34
+
35
+ interface UseGlideCellContentResult {
36
+ getCellContent: (cell: Item) => GridCell;
37
+ onCellEdited: (cell: Item, newValue: EditableGridCell) => void;
38
+ }
39
+
40
+ /**
41
+ * Provides getCellContent and onCellEdited callbacks for Glide Data Grid.
42
+ * Uses refs for rows/columnOrder to avoid stale closures in canvas render loop.
43
+ * Integrates with pending edits — shows pending values with amber highlight.
44
+ */
45
+ export function useGlideCellContent(
46
+ rows: Record<string, unknown>[],
47
+ columnOrder: string[],
48
+ schema: GridColumnSchema[],
49
+ pkCol: string | null,
50
+ addPendingEdit: (pkVal: unknown, col: string, newVal: unknown) => void,
51
+ pendingRef: React.RefObject<Map<string, PendingEdit>>,
52
+ ): UseGlideCellContentResult {
53
+ const rowsRef = useRef(rows);
54
+ rowsRef.current = rows;
55
+ const colOrderRef = useRef(columnOrder);
56
+ colOrderRef.current = columnOrder;
57
+
58
+ const schemaMap = useRef(new Map<string, GridColumnSchema>());
59
+ schemaMap.current = new Map(schema.map((s) => [s.name, s]));
60
+
61
+ const getCellContent = useCallback(([colIdx, rowIdx]: Item): GridCell => {
62
+ const colName = colOrderRef.current[colIdx];
63
+ const row = rowsRef.current[rowIdx];
64
+ if (!colName || !row) {
65
+ return { kind: GridCellKind.Text, data: "", displayData: "", allowOverlay: false };
66
+ }
67
+
68
+ const colSchema = schemaMap.current.get(colName);
69
+ const kind = colSchema ? dbTypeToKind(colSchema.type) : GridCellKind.Text;
70
+ const isPk = colSchema?.pk ?? false;
71
+
72
+ // Check for pending edit value
73
+ const pkVal = pkCol ? row[pkCol] : undefined;
74
+ const isNewRow = typeof pkVal === "string" && pkVal.startsWith("__new_");
75
+ const pendingKey = `${pkVal}:${colName}`;
76
+ const pending = pendingRef.current.get(pendingKey);
77
+ const val = pending !== undefined ? pending.newVal : row[colName];
78
+ const hasPending = pending !== undefined;
79
+
80
+ // New row PK column — auto-increment PKs show "NEW" (readonly), text PKs are editable
81
+ if (isPk && isNewRow) {
82
+ const isAuto = colSchema ? isAutoIncrement(colSchema) : false;
83
+ if (isAuto) {
84
+ return { kind: GridCellKind.Text, data: "", displayData: "AUTO", allowOverlay: false, readonly: true,
85
+ themeOverride: { textDark: "#6b7280" } };
86
+ }
87
+ // Editable PK (text, uuid, etc.) — show pending value or placeholder
88
+ const editedVal = pending !== undefined ? String(pending.newVal ?? "") : "";
89
+ return { kind: GridCellKind.Text, data: editedVal, displayData: editedVal || "Enter ID…",
90
+ allowOverlay: true, readonly: false,
91
+ themeOverride: editedVal ? (hasPending ? PENDING_THEME : undefined) : { textDark: "#9ca3af" } };
92
+ }
93
+
94
+ // NULL values — for new rows show default/type hints
95
+ if (val == null) {
96
+ let placeholder = isNewRow ? "" : "NULL";
97
+ if (isNewRow && !hasPending && colSchema) {
98
+ const t = colSchema.type.toLowerCase();
99
+ if (colSchema.defaultValue) {
100
+ placeholder = colSchema.defaultValue;
101
+ } else if (/^(timestamp|datetime|date)/.test(t)) {
102
+ placeholder = "NOW()";
103
+ } else if (/^(uuid)/.test(t)) {
104
+ placeholder = "gen_random_uuid()";
105
+ }
106
+ }
107
+ return {
108
+ kind: GridCellKind.Text, data: "", displayData: placeholder,
109
+ allowOverlay: !isPk, readonly: isPk,
110
+ themeOverride: hasPending ? PENDING_THEME : (isNewRow || placeholder !== "NULL" ? { textDark: "#9ca3af" } : { textDark: "#6b7280" }),
111
+ };
112
+ }
113
+
114
+ // Number cells
115
+ if (kind === GridCellKind.Number && typeof val === "number") {
116
+ return {
117
+ kind: GridCellKind.Number, data: val, displayData: String(val),
118
+ allowOverlay: !isPk, readonly: isPk,
119
+ themeOverride: hasPending ? PENDING_THEME : undefined,
120
+ };
121
+ }
122
+
123
+ // Boolean cells
124
+ if (kind === GridCellKind.Boolean && typeof val === "boolean") {
125
+ return { kind: GridCellKind.Boolean, data: val, readonly: isPk, allowOverlay: false };
126
+ }
127
+
128
+ // Text cell
129
+ const strVal = formatCellValue(val);
130
+ return {
131
+ kind: GridCellKind.Text, data: strVal, displayData: truncateDisplay(strVal),
132
+ allowOverlay: !isPk, readonly: isPk,
133
+ themeOverride: hasPending ? PENDING_THEME : undefined,
134
+ };
135
+ }, []); // stable — reads from refs
136
+
137
+ const onCellEdited = useCallback(([colIdx, rowIdx]: Item, newValue: EditableGridCell) => {
138
+ if (!pkCol) return;
139
+ const colName = colOrderRef.current[colIdx];
140
+ const row = rowsRef.current[rowIdx];
141
+ if (!colName || !row) return;
142
+
143
+ const pkVal = row[pkCol];
144
+ let parsed: unknown;
145
+ if (newValue.kind === GridCellKind.Text) {
146
+ parsed = newValue.data === "" ? null : newValue.data;
147
+ } else if (newValue.kind === GridCellKind.Number) {
148
+ parsed = newValue.data;
149
+ } else if (newValue.kind === GridCellKind.Boolean) {
150
+ parsed = newValue.data;
151
+ } else {
152
+ return;
153
+ }
154
+
155
+ addPendingEdit(pkVal, colName, parsed);
156
+ }, [pkCol, addPendingEdit]);
157
+
158
+ return { getCellContent, onCellEdited };
159
+ }
@@ -0,0 +1,69 @@
1
+ import { useMemo } from "react";
2
+ import type { GridColumn } from "@glideapps/glide-data-grid";
3
+ import type { GridColumnSchema } from "./glide-grid-types";
4
+
5
+ interface UseGlideColumnsResult {
6
+ /** Ordered GridColumn definitions (pinned first) */
7
+ columns: GridColumn[];
8
+ /** Number of frozen columns from left */
9
+ freezeColumns: number;
10
+ /** Column name order matching GridColumn indices */
11
+ columnOrder: string[];
12
+ }
13
+
14
+ /** Estimate column width from header name and sample row values */
15
+ function estimateColWidth(name: string, rows: Record<string, unknown>[], type: string): number {
16
+ const headerW = name.length * 9 + 40; // header text + sort icon + menu icon padding
17
+ let maxContentW = 0;
18
+ const sampleCount = Math.min(rows.length, 20);
19
+ for (let i = 0; i < sampleCount; i++) {
20
+ const val = rows[i]?.[name];
21
+ if (val == null) continue;
22
+ const len = typeof val === "object" ? 12 : String(val).length;
23
+ maxContentW = Math.max(maxContentW, len * 8);
24
+ }
25
+ const isNumeric = /^(int|serial|bigint|smallint|float|double|decimal|numeric|real|money|bool)/.test(type.toLowerCase());
26
+ const minW = isNumeric ? 80 : 100;
27
+ return Math.max(minW, Math.min(Math.max(headerW, maxContentW) + 16, 400));
28
+ }
29
+
30
+ /**
31
+ * Build Glide Data Grid column definitions from schema.
32
+ * Reorders columns: pinned first, then unpinned. Auto-sizes widths.
33
+ */
34
+ export function useGlideColumns(
35
+ schema: GridColumnSchema[],
36
+ columnNames: string[],
37
+ pinnedCols: Set<string>,
38
+ colWidths: Map<string, number>,
39
+ rows: Record<string, unknown>[],
40
+ orderBy?: string | null,
41
+ orderDir?: "ASC" | "DESC",
42
+ ): UseGlideColumnsResult {
43
+ return useMemo(() => {
44
+ const pinned = columnNames.filter((c) => pinnedCols.has(c));
45
+ const unpinned = columnNames.filter((c) => !pinnedCols.has(c));
46
+ const ordered = [...pinned, ...unpinned];
47
+
48
+ const schemaMap = new Map(schema.map((s) => [s.name, s]));
49
+
50
+ const columns: GridColumn[] = ordered.map((name) => {
51
+ const col = schemaMap.get(name);
52
+ const isPk = col?.pk ?? false;
53
+
54
+ let icon: string | undefined;
55
+ if (orderBy === name) {
56
+ icon = orderDir === "ASC" ? "sortAsc" : "sortDesc";
57
+ } else if (isPk) {
58
+ icon = "headerRowID";
59
+ } else if (col?.fk) {
60
+ icon = "headerFk";
61
+ }
62
+
63
+ const width = colWidths.get(name) ?? estimateColWidth(name, rows, col?.type ?? "text");
64
+ return { title: name, id: name, width, hasMenu: true, icon };
65
+ });
66
+
67
+ return { columns, freezeColumns: pinned.length, columnOrder: ordered };
68
+ }, [schema, columnNames, pinnedCols, colWidths, rows, orderBy, orderDir]);
69
+ }
@@ -0,0 +1,164 @@
1
+ import { useState, useCallback, useRef, useEffect } from "react";
2
+ import { useShallow } from "zustand/react/shallow";
3
+ import { useTabStore } from "@/stores/tab-store";
4
+ import type { Item, GridSelection } from "@glideapps/glide-data-grid";
5
+ import type { GridColumnSchema } from "./glide-grid-types";
6
+ import { formatCellValue, detectLang, needsViewer } from "./glide-grid-types";
7
+ import type { PreviewData } from "./glide-data-preview-panel";
8
+
9
+ interface UseGlideGridActionsParams {
10
+ displayRows: Record<string, unknown>[];
11
+ columnOrder: string[];
12
+ schema: GridColumnSchema[];
13
+ pkCol: string | null;
14
+ connectionId?: number;
15
+ connectionName?: string;
16
+ selectedTable?: string | null;
17
+ selectedSchema?: string;
18
+ addEdit: (pkVal: unknown, col: string, newVal: unknown) => void;
19
+ /** Current grid selection — needed for document-level paste */
20
+ gridSelection?: GridSelection;
21
+ /** Container ref — paste only fires when focus is inside */
22
+ containerRef?: React.RefObject<HTMLElement | null>;
23
+ }
24
+
25
+ /**
26
+ * Extracts preview panel, paste handler, and FK navigation logic
27
+ * from the main GlideDataGrid component to keep it under 200 lines.
28
+ */
29
+ export function useGlideGridActions(params: UseGlideGridActionsParams) {
30
+ const { displayRows, columnOrder, schema, pkCol, connectionId, connectionName, selectedTable, selectedSchema, addEdit, gridSelection, containerRef } = params;
31
+ const { openTab } = useTabStore(useShallow((s) => ({ openTab: s.openTab })));
32
+ const [previewData, setPreviewData] = useState<PreviewData | null>(null);
33
+
34
+ // Refs to avoid stale closures in canvas callbacks
35
+ const displayRowsRef = useRef(displayRows);
36
+ displayRowsRef.current = displayRows;
37
+ const columnOrderRef = useRef(columnOrder);
38
+ columnOrderRef.current = columnOrder;
39
+
40
+ // Preview panel — inline Monaco viewer for cell/row content
41
+ const openRowPreview = useCallback((rowIdx: number) => {
42
+ const row = displayRows[rowIdx]; if (!row) return;
43
+ const pk = pkCol ? String(row[pkCol] ?? "") : "";
44
+ const table = selectedTable ?? "";
45
+ const content = JSON.stringify(row, null, 2);
46
+ setPreviewData({ title: pk ? `Row #${pk}${table ? ` — ${table}` : ""}` : `Row — ${table}`,
47
+ content, language: "json", viewerKey: `${connectionId}:${table}:row:${pk}` });
48
+ }, [displayRows, pkCol, selectedTable, connectionId]);
49
+
50
+ const openCellPreview = useCallback((rowIdx: number, colIdx: number) => {
51
+ const row = displayRows[rowIdx]; if (!row) return;
52
+ const colName = columnOrder[colIdx]; if (!colName) return;
53
+ const val = formatCellValue(row[colName]);
54
+ const pk = pkCol ? String(row[pkCol] ?? rowIdx) : String(rowIdx);
55
+ const table = selectedTable ?? "";
56
+ setPreviewData({ title: `${colName} #${pk}${table ? ` — ${table}` : ""}`,
57
+ content: val, language: detectLang(val), viewerKey: `${connectionId}:${table}:${colName}:${pk}` });
58
+ }, [displayRows, columnOrder, pkCol, selectedTable, connectionId]);
59
+
60
+ const openPreviewInTab = useCallback(() => {
61
+ if (!previewData) return;
62
+ openTab({ type: "editor", title: previewData.title, projectId: null, closable: true,
63
+ metadata: { inlineContent: previewData.content, inlineLanguage: previewData.language, viewerKey: previewData.viewerKey } });
64
+ }, [openTab, previewData]);
65
+
66
+ // Custom paste handler — routes pasted TSV cells through addEdit
67
+ const handlePaste = useCallback((target: Item, values: readonly (readonly string[])[]) => {
68
+ if (!pkCol) return false;
69
+ const [startCol, startRow] = target;
70
+ for (let r = 0; r < values.length; r++) {
71
+ const row = displayRowsRef.current[startRow + r];
72
+ if (!row) continue;
73
+ const pk = row[pkCol];
74
+ for (let c = 0; c < values[r]!.length; c++) {
75
+ const colName = columnOrderRef.current[startCol + c];
76
+ if (!colName) continue;
77
+ const colDef = schema.find((s) => s.name === colName);
78
+ if (colDef?.pk) continue;
79
+ const raw = values[r]![c]!;
80
+ addEdit(pk, colName, raw === "" ? null : raw);
81
+ }
82
+ }
83
+ return false; // we handled it
84
+ }, [pkCol, schema, addEdit]);
85
+
86
+ // Document-level paste listener — works even when Glide canvas doesn't have focus
87
+ const schemaRef = useRef(schema);
88
+ schemaRef.current = schema;
89
+ const gridSelRef = useRef(gridSelection);
90
+ gridSelRef.current = gridSelection;
91
+ const addEditRef = useRef(addEdit);
92
+ addEditRef.current = addEdit;
93
+ const pkColRef = useRef(pkCol);
94
+ pkColRef.current = pkCol;
95
+
96
+ useEffect(() => {
97
+ if (!containerRef) return;
98
+ const handler = (e: ClipboardEvent) => {
99
+ const container = containerRef.current;
100
+ if (!container || !container.contains(document.activeElement)) return;
101
+ // Skip if an input/textarea is focused (e.g. search bar)
102
+ const tag = (document.activeElement as HTMLElement)?.tagName;
103
+ if (tag === "INPUT" || tag === "TEXTAREA") return;
104
+ const pk = pkColRef.current;
105
+ if (!pk) return;
106
+ const sel = gridSelRef.current?.current;
107
+ if (!sel) return; // need a selected cell as paste anchor
108
+ const text = e.clipboardData?.getData("text/plain");
109
+ if (!text) return;
110
+ const tsvRows = text.split(/\r?\n/).filter((r) => r.length > 0).map((r) => r.split("\t"));
111
+ if (tsvRows.length === 0) return;
112
+ const [startCol, startRow] = sel.cell;
113
+ for (let r = 0; r < tsvRows.length; r++) {
114
+ const row = displayRowsRef.current[startRow + r];
115
+ if (!row) continue;
116
+ const rowPk = row[pk];
117
+ for (let c = 0; c < tsvRows[r]!.length; c++) {
118
+ const colName = columnOrderRef.current[startCol + c];
119
+ if (!colName) continue;
120
+ const colDef = schemaRef.current.find((s) => s.name === colName);
121
+ if (colDef?.pk) continue;
122
+ const raw = tsvRows[r]![c]!;
123
+ addEditRef.current(rowPk, colName, raw === "" ? null : raw);
124
+ }
125
+ }
126
+ e.preventDefault();
127
+ };
128
+ document.addEventListener("paste", handler);
129
+ return () => document.removeEventListener("paste", handler);
130
+ }, [containerRef]);
131
+
132
+ // FK detection helpers for context menu
133
+ const getContextFk = useCallback((colName: string | null) => {
134
+ if (!colName) return null;
135
+ return schema.find((s) => s.name === colName)?.fk ?? null;
136
+ }, [schema]);
137
+
138
+ const isCellViewable = useCallback((row: Record<string, unknown> | null, colName: string | null) => {
139
+ return row && colName ? needsViewer(row[colName]) : false;
140
+ }, []);
141
+
142
+ // FK navigation: open referenced table in new tab filtered by FK value
143
+ const openFkTable = useCallback((fk: { table: string; column: string }, cellValue: unknown) => {
144
+ if (cellValue == null || !connectionId) return;
145
+ openTab({
146
+ type: "database",
147
+ title: `${connectionName ?? "DB"} · ${fk.table}`,
148
+ projectId: null,
149
+ closable: true,
150
+ metadata: {
151
+ connectionId, connectionName,
152
+ tableName: fk.table,
153
+ schemaName: selectedSchema ?? "public",
154
+ initialSql: `SELECT * FROM "${fk.table}" WHERE "${fk.column}" = '${String(cellValue).replace(/'/g, "''")}'`,
155
+ },
156
+ });
157
+ }, [connectionId, connectionName, selectedSchema, openTab]);
158
+
159
+ return {
160
+ previewData, setPreviewData,
161
+ openRowPreview, openCellPreview, openPreviewInTab,
162
+ handlePaste, getContextFk, isCellViewable, openFkTable,
163
+ };
164
+ }