@hienlh/ppm 0.13.9 → 0.13.11

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 (113) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/assets/skills/ppm/SKILL.md +1 -1
  3. package/assets/skills/ppm/references/http-api.md +1 -1
  4. package/bun.lock +2135 -0
  5. package/bunfig.toml +2 -0
  6. package/dist/web/assets/ai-settings-section-ysK_Eixc.js +1 -0
  7. package/dist/web/assets/architecture-PBZL5I3N-By4Nv3Gj.js +1 -0
  8. package/dist/web/assets/{audio-preview-C1p-Q5XZ.js → audio-preview-dOvnFQN8.js} +1 -1
  9. package/dist/web/assets/chat-tab-C6NVtLx3.js +12 -0
  10. package/dist/web/assets/code-editor-3e9UAnzv.js +8 -0
  11. package/dist/web/assets/{conflict-editor-Dcn3HuLD.js → conflict-editor-XMpuwtUv.js} +1 -1
  12. package/dist/web/assets/csv-parser-Dly5nqE1.js +6 -0
  13. package/dist/web/assets/{csv-preview-D5lmgVEy.js → csv-preview-7TsYBQI6.js} +3 -3
  14. package/dist/web/assets/data-grid-overlay-editor-BjjuE4-G.js +1 -0
  15. package/dist/web/assets/data-grid-types-BTQHYBUh.js +1 -0
  16. package/dist/web/assets/database-viewer-GNKYeCIc.js +1 -0
  17. package/dist/web/assets/{diff-viewer-NMLD4V8q.js → diff-viewer-DjFe6idy.js} +1 -1
  18. package/dist/web/assets/dist-0kPgRaVx.js +1 -0
  19. package/dist/web/assets/{esm-nXReYVnB.js → esm-zjerHxpO.js} +1 -1
  20. package/dist/web/assets/{extension-webview-DW2dBswj.js → extension-webview-hgbO93RZ.js} +1 -1
  21. package/dist/web/assets/gitGraph-HDMCJU4V-BLXEKVf1.js +1 -0
  22. package/dist/web/assets/glide-data-grid-nthEL3fk.css +1 -0
  23. package/dist/web/assets/glide-data-grid-uLRTmkwH.js +136 -0
  24. package/dist/web/assets/{image-preview-Dqp1KSus.js → image-preview-CzwOU5op.js} +1 -1
  25. package/dist/web/assets/index-BAPR3hYQ.js +27 -0
  26. package/dist/web/assets/index-COOnLKGB.css +2 -0
  27. package/dist/web/assets/info-3K5VOQVL-CEkPcChg.js +1 -0
  28. package/dist/web/assets/{input-DYWhyaze.js → input-ozrR2DAV.js} +1 -1
  29. package/dist/web/assets/keybindings-store-D9GV2h8K.js +1 -0
  30. package/dist/web/assets/{markdown-renderer-DNIXdY0d.js → markdown-renderer-BD5P19YN.js} +3 -3
  31. package/dist/web/assets/number-overlay-editor-BoRxunFN.js +9 -0
  32. package/dist/web/assets/packet-RMMSAZCW-DECxYTOi.js +1 -0
  33. package/dist/web/assets/{pdf-preview-ChC1gaaZ.js → pdf-preview-DRbBRYIF.js} +1 -1
  34. package/dist/web/assets/pie-UPGHQEXC-cjpNfVG5.js +1 -0
  35. package/dist/web/assets/{port-forwarding-tab-dLhH_g2l.js → port-forwarding-tab-BSFhNv-A.js} +1 -1
  36. package/dist/web/assets/{postgres-viewer-De0pzd1C.js → postgres-viewer-D51L9fxD.js} +3 -3
  37. package/dist/web/assets/radar-KQ55EAFF-Dnpi068b.js +1 -0
  38. package/dist/web/assets/{settings-store-BHBb62gq.js → settings-store-B-OmHo3J.js} +1 -1
  39. package/dist/web/assets/settings-tab-DdbYEmAC.js +1 -0
  40. package/dist/web/assets/sql-query-editor-7pD60nKZ.js +3 -0
  41. package/dist/web/assets/sqlite-viewer-DLNJ_IGM.js +1 -0
  42. package/dist/web/assets/terminal-tab-6T5e8Nar.js +1 -0
  43. package/dist/web/assets/treemap-KZPCXAKY-DRyb1eiw.js +1 -0
  44. package/dist/web/assets/{use-monaco-theme-CP-vyTF8.js → use-monaco-theme-DgzxiZS5.js} +1 -1
  45. package/dist/web/assets/{vendor-mermaid-CMiurk2b.js → vendor-mermaid-CCmA_6Y0.js} +3 -3
  46. package/dist/web/assets/{video-preview-CHPVrMtx.js → video-preview-BwDNFl7v.js} +1 -1
  47. package/dist/web/assets/x-CG-_0yIW.js +1 -0
  48. package/dist/web/index.html +10 -11
  49. package/dist/web/sw.js +1 -1
  50. package/package.json +1 -1
  51. package/src/index.ts +0 -0
  52. package/src/web/components/chat/chat-tab.tsx +4 -1
  53. package/src/web/components/chat/message-input.tsx +28 -14
  54. package/src/web/components/database/database-viewer.tsx +19 -8
  55. package/src/web/components/database/export-button.tsx +38 -18
  56. package/src/web/components/database/glide-column-search.tsx +81 -0
  57. package/src/web/components/database/glide-context-menu.tsx +95 -0
  58. package/src/web/components/database/glide-data-grid.tsx +207 -0
  59. package/src/web/components/database/glide-data-preview-panel.tsx +113 -0
  60. package/src/web/components/database/glide-grid-pagination.tsx +34 -0
  61. package/src/web/components/database/glide-grid-toolbar.tsx +105 -0
  62. package/src/web/components/database/glide-grid-types.ts +2 -0
  63. package/src/web/components/database/glide-header-menu.tsx +111 -0
  64. package/src/web/components/database/glide-save-bar.tsx +33 -0
  65. package/src/web/components/database/sql-query-editor.tsx +14 -4
  66. package/src/web/components/database/use-database.ts +10 -2
  67. package/src/web/components/database/use-glide-cell-content.ts +65 -30
  68. package/src/web/components/database/use-glide-columns.ts +24 -16
  69. package/src/web/components/database/use-glide-grid-actions.ts +164 -0
  70. package/src/web/components/database/use-glide-pending-edits.ts +72 -0
  71. package/src/web/components/database/use-glide-row-pinning.ts +35 -0
  72. package/src/web/components/editor/code-editor.tsx +7 -5
  73. package/src/web/components/layout/editor-panel.tsx +2 -2
  74. package/src/web/components/sqlite/sqlite-viewer.tsx +21 -12
  75. package/src/web/components/sqlite/use-sqlite.ts +1 -1
  76. package/src/web/hooks/use-chat.ts +27 -20
  77. package/src/web/hooks/use-global-keybindings.ts +12 -0
  78. package/src/web/hooks/use-terminal.ts +1 -1
  79. package/dist/web/assets/ai-settings-section-CLNBWLS4.js +0 -1
  80. package/dist/web/assets/architecture-PBZL5I3N-WMbLpD5Y.js +0 -1
  81. package/dist/web/assets/chat-tab-BSJUkgxB.js +0 -12
  82. package/dist/web/assets/code-editor-rNw5_pXh.js +0 -8
  83. package/dist/web/assets/csv-parser-DO0dz4x_.js +0 -6
  84. package/dist/web/assets/data-grid-nZfSIop5.js +0 -5
  85. package/dist/web/assets/database-viewer-CNoq5Uxp.js +0 -1
  86. package/dist/web/assets/gitGraph-HDMCJU4V-BdPTuzO3.js +0 -1
  87. package/dist/web/assets/index-CoMWx5VS.js +0 -27
  88. package/dist/web/assets/index-Dzb3OtrX.css +0 -2
  89. package/dist/web/assets/info-3K5VOQVL-MHX_1JfR.js +0 -1
  90. package/dist/web/assets/keybindings-store-B7nlHmDh.js +0 -1
  91. package/dist/web/assets/packet-RMMSAZCW-CreFbf9A.js +0 -1
  92. package/dist/web/assets/pie-UPGHQEXC-CnaHXUh8.js +0 -1
  93. package/dist/web/assets/radar-KQ55EAFF-UxsdRHvt.js +0 -1
  94. package/dist/web/assets/settings-tab-Mrs9uzCZ.js +0 -1
  95. package/dist/web/assets/sql-completion-provider-tCzZfqWs.js +0 -1
  96. package/dist/web/assets/sql-query-editor-CMQpaOjA.js +0 -3
  97. package/dist/web/assets/sqlite-viewer-BqtIjvil.js +0 -1
  98. package/dist/web/assets/terminal-tab-CeHEtoE2.js +0 -1
  99. package/dist/web/assets/trash-2-CJYoLw7Q.js +0 -1
  100. package/dist/web/assets/treemap-KZPCXAKY-CBVPi4NV.js +0 -1
  101. package/dist/web/assets/x-OGGXhtlb.js +0 -1
  102. /package/dist/web/assets/{arrow-up-Dtrfv490.js → arrow-up-Rcw6_KKu.js} +0 -0
  103. /package/dist/web/assets/{dist-D7KGU7Vl.js → dist-CaKCIxem.js} +0 -0
  104. /package/dist/web/assets/{dist-CGvx1c8C.js → dist-DGSkE2Ml.js} +0 -0
  105. /package/dist/web/assets/{katex-BFE6i_OH.js → katex-BuytEdO1.js} +0 -0
  106. /package/dist/web/assets/{lib-D_kRA9p6.js → lib-DQHnkzGy.js} +0 -0
  107. /package/dist/web/assets/{refresh-cw-CSFrDtiu.js → refresh-cw-LlbZDJpO.js} +0 -0
  108. /package/dist/web/assets/{scroll-area-BEllam7_.js → scroll-area-7H-Q_k8c.js} +0 -0
  109. /package/dist/web/assets/{sparkles-B0mRBy_j.js → sparkles-fWUT5Vzq.js} +0 -0
  110. /package/dist/web/assets/{table-Dq575bPF.js → table-tf7pRkME.js} +0 -0
  111. /package/dist/web/assets/{text-wrap-Cn6BNQfq.js → text-wrap-BV-R4Vvy.js} +0 -0
  112. /package/dist/web/assets/{use-blob-url-Hn6n1730.js → use-blob-url-e9uTXjv5.js} +0 -0
  113. /package/dist/web/assets/{vendor-xterm-u3AZMvTx.js → vendor-xterm-CU2c3f0A.js} +0 -0
@@ -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,
@@ -2,6 +2,7 @@ import { useCallback, useRef } from "react";
2
2
  import { GridCellKind, type GridCell, type EditableGridCell, type Item } from "@glideapps/glide-data-grid";
3
3
  import type { GridColumnSchema } from "./glide-grid-types";
4
4
  import { formatCellValue } from "./glide-grid-types";
5
+ import type { PendingEdit } from "./use-glide-pending-edits";
5
6
 
6
7
  /** Map DB type string to Glide cell kind */
7
8
  function dbTypeToKind(type: string): GridCellKind {
@@ -13,11 +14,24 @@ function dbTypeToKind(type: string): GridCellKind {
13
14
  return GridCellKind.Text;
14
15
  }
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
+
16
27
  /** Truncate display string for canvas rendering performance */
17
28
  function truncateDisplay(val: string, max = 200): string {
18
29
  return val.length > max ? val.slice(0, max) + "…" : val;
19
30
  }
20
31
 
32
+ /** Amber background for cells with pending unsaved edits */
33
+ const PENDING_THEME = { bgCell: "rgba(251, 191, 36, 0.15)" };
34
+
21
35
  interface UseGlideCellContentResult {
22
36
  getCellContent: (cell: Item) => GridCell;
23
37
  onCellEdited: (cell: Item, newValue: EditableGridCell) => void;
@@ -26,15 +40,16 @@ interface UseGlideCellContentResult {
26
40
  /**
27
41
  * Provides getCellContent and onCellEdited callbacks for Glide Data Grid.
28
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.
29
44
  */
30
45
  export function useGlideCellContent(
31
46
  rows: Record<string, unknown>[],
32
47
  columnOrder: string[],
33
48
  schema: GridColumnSchema[],
34
49
  pkCol: string | null,
35
- onCellUpdate: (pkCol: string, pkVal: unknown, col: string, val: unknown) => void,
50
+ addPendingEdit: (pkVal: unknown, col: string, newVal: unknown) => void,
51
+ pendingRef: React.RefObject<Map<string, PendingEdit>>,
36
52
  ): UseGlideCellContentResult {
37
- // Use refs to avoid stale data in canvas callbacks
38
53
  const rowsRef = useRef(rows);
39
54
  rowsRef.current = rows;
40
55
  const colOrderRef = useRef(columnOrder);
@@ -50,52 +65,72 @@ export function useGlideCellContent(
50
65
  return { kind: GridCellKind.Text, data: "", displayData: "", allowOverlay: false };
51
66
  }
52
67
 
53
- const val = row[colName];
54
68
  const colSchema = schemaMap.current.get(colName);
55
69
  const kind = colSchema ? dbTypeToKind(colSchema.type) : GridCellKind.Text;
56
70
  const isPk = colSchema?.pk ?? false;
57
71
 
58
- // NULL values styled as italic gray text
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
59
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
+ }
60
107
  return {
61
- kind: GridCellKind.Text,
62
- data: "",
63
- displayData: "NULL",
64
- allowOverlay: !isPk,
65
- readonly: isPk,
66
- themeOverride: { textDark: "#6b7280" },
108
+ kind: GridCellKind.Text, data: "", displayData: placeholder,
109
+ allowOverlay: !isPk, readonly: isPk,
110
+ themeOverride: hasPending ? PENDING_THEME : (isNewRow || placeholder !== "NULL" ? { textDark: "#9ca3af" } : { textDark: "#6b7280" }),
67
111
  };
68
112
  }
69
113
 
70
114
  // Number cells
71
115
  if (kind === GridCellKind.Number && typeof val === "number") {
72
116
  return {
73
- kind: GridCellKind.Number,
74
- data: val,
75
- displayData: String(val),
76
- allowOverlay: !isPk,
77
- readonly: isPk,
117
+ kind: GridCellKind.Number, data: val, displayData: String(val),
118
+ allowOverlay: !isPk, readonly: isPk,
119
+ themeOverride: hasPending ? PENDING_THEME : undefined,
78
120
  };
79
121
  }
80
122
 
81
- // Boolean cells — display only, edit via text overlay
123
+ // Boolean cells
82
124
  if (kind === GridCellKind.Boolean && typeof val === "boolean") {
83
- return {
84
- kind: GridCellKind.Boolean,
85
- data: val,
86
- readonly: isPk,
87
- allowOverlay: false,
88
- };
125
+ return { kind: GridCellKind.Boolean, data: val, readonly: isPk, allowOverlay: false };
89
126
  }
90
127
 
91
- // Default: Text cell
128
+ // Text cell
92
129
  const strVal = formatCellValue(val);
93
130
  return {
94
- kind: GridCellKind.Text,
95
- data: strVal,
96
- displayData: truncateDisplay(strVal),
97
- allowOverlay: !isPk,
98
- readonly: isPk,
131
+ kind: GridCellKind.Text, data: strVal, displayData: truncateDisplay(strVal),
132
+ allowOverlay: !isPk, readonly: isPk,
133
+ themeOverride: hasPending ? PENDING_THEME : undefined,
99
134
  };
100
135
  }, []); // stable — reads from refs
101
136
 
@@ -117,8 +152,8 @@ export function useGlideCellContent(
117
152
  return;
118
153
  }
119
154
 
120
- onCellUpdate(pkCol, pkVal, colName, parsed);
121
- }, [pkCol, onCellUpdate]);
155
+ addPendingEdit(pkVal, colName, parsed);
156
+ }, [pkCol, addPendingEdit]);
122
157
 
123
158
  return { getCellContent, onCellEdited };
124
159
  }
@@ -11,20 +11,36 @@ interface UseGlideColumnsResult {
11
11
  columnOrder: string[];
12
12
  }
13
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
+
14
30
  /**
15
31
  * Build Glide Data Grid column definitions from schema.
16
- * Reorders columns: pinned first, then unpinned.
32
+ * Reorders columns: pinned first, then unpinned. Auto-sizes widths.
17
33
  */
18
34
  export function useGlideColumns(
19
35
  schema: GridColumnSchema[],
20
36
  columnNames: string[],
21
37
  pinnedCols: Set<string>,
22
38
  colWidths: Map<string, number>,
39
+ rows: Record<string, unknown>[],
23
40
  orderBy?: string | null,
24
41
  orderDir?: "ASC" | "DESC",
25
42
  ): UseGlideColumnsResult {
26
43
  return useMemo(() => {
27
- // Reorder: pinned first, then rest (preserve original order within each group)
28
44
  const pinned = columnNames.filter((c) => pinnedCols.has(c));
29
45
  const unpinned = columnNames.filter((c) => !pinnedCols.has(c));
30
46
  const ordered = [...pinned, ...unpinned];
@@ -35,27 +51,19 @@ export function useGlideColumns(
35
51
  const col = schemaMap.get(name);
36
52
  const isPk = col?.pk ?? false;
37
53
 
38
- // Determine sort icon
39
54
  let icon: string | undefined;
40
55
  if (orderBy === name) {
41
56
  icon = orderDir === "ASC" ? "sortAsc" : "sortDesc";
42
57
  } else if (isPk) {
43
58
  icon = "headerRowID";
59
+ } else if (col?.fk) {
60
+ icon = "headerFk";
44
61
  }
45
62
 
46
- return {
47
- title: name,
48
- id: name,
49
- width: colWidths.get(name) ?? 150,
50
- hasMenu: true,
51
- icon,
52
- };
63
+ const width = colWidths.get(name) ?? estimateColWidth(name, rows, col?.type ?? "text");
64
+ return { title: name, id: name, width, hasMenu: true, icon };
53
65
  });
54
66
 
55
- return {
56
- columns,
57
- freezeColumns: pinned.length,
58
- columnOrder: ordered,
59
- };
60
- }, [schema, columnNames, pinnedCols, colWidths, orderBy, orderDir]);
67
+ return { columns, freezeColumns: pinned.length, columnOrder: ordered };
68
+ }, [schema, columnNames, pinnedCols, colWidths, rows, orderBy, orderDir]);
61
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
+ }
@@ -0,0 +1,72 @@
1
+ import { useState, useCallback, useRef } from "react";
2
+
3
+ export interface PendingEdit {
4
+ pkVal: unknown;
5
+ col: string;
6
+ newVal: unknown;
7
+ }
8
+
9
+ interface UseGlidePendingEditsResult {
10
+ pendingEdits: Map<string, PendingEdit>;
11
+ pendingRef: React.RefObject<Map<string, PendingEdit>>;
12
+ addEdit: (pkVal: unknown, col: string, newVal: unknown) => void;
13
+ commitAll: () => Promise<void>;
14
+ discardAll: () => void;
15
+ hasPending: boolean;
16
+ pendingCount: number;
17
+ /** True after commitAll until cleared — used to clear edits on rows refresh */
18
+ committedRef: React.RefObject<boolean>;
19
+ }
20
+
21
+ /**
22
+ * Tracks cell edits locally until the user explicitly saves.
23
+ * After commit, keeps pending values visible until rows refresh (avoids flash of stale data).
24
+ * Supports inline insert: edits with PK starting "__new_" are routed to onInsertRow.
25
+ */
26
+ export function useGlidePendingEdits(
27
+ pkCol: string | null,
28
+ onCellUpdate: (pkCol: string, pkVal: unknown, col: string, val: unknown) => void,
29
+ onInsertRow?: (values: Record<string, unknown>) => Promise<void>,
30
+ ): UseGlidePendingEditsResult {
31
+ const [pendingEdits, setPendingEdits] = useState<Map<string, PendingEdit>>(new Map());
32
+ const pendingRef = useRef(pendingEdits);
33
+ pendingRef.current = pendingEdits;
34
+ const committedRef = useRef(false);
35
+
36
+ const addEdit = useCallback((pkVal: unknown, col: string, newVal: unknown) => {
37
+ const key = `${pkVal}:${col}`;
38
+ setPendingEdits((prev) => new Map(prev).set(key, { pkVal, col, newVal }));
39
+ }, []);
40
+
41
+ const commitAll = useCallback(async () => {
42
+ if (!pkCol) return;
43
+ const newRows = new Map<string, Record<string, unknown>>();
44
+ for (const edit of pendingRef.current.values()) {
45
+ const pkStr = String(edit.pkVal);
46
+ if (pkStr.startsWith("__new_")) {
47
+ if (!newRows.has(pkStr)) newRows.set(pkStr, {});
48
+ newRows.get(pkStr)![edit.col] = edit.newVal;
49
+ } else {
50
+ onCellUpdate(pkCol, edit.pkVal, edit.col, edit.newVal);
51
+ }
52
+ }
53
+ if (onInsertRow) {
54
+ for (const values of newRows.values()) {
55
+ await onInsertRow(values);
56
+ }
57
+ }
58
+ committedRef.current = true;
59
+ // Don't clear — wait for rows prop to refresh so grid doesn't flash stale data
60
+ }, [pkCol, onCellUpdate, onInsertRow]);
61
+
62
+ const discardAll = useCallback(() => {
63
+ setPendingEdits(new Map());
64
+ committedRef.current = false;
65
+ }, []);
66
+
67
+ return {
68
+ pendingEdits, pendingRef, addEdit, commitAll, discardAll,
69
+ hasPending: pendingEdits.size > 0, pendingCount: pendingEdits.size,
70
+ committedRef,
71
+ };
72
+ }
@@ -0,0 +1,35 @@
1
+ import { useState, useMemo } from "react";
2
+
3
+ interface UseGlideRowPinningResult {
4
+ /** Rows reordered: unpinned first, pinned at end (frozen via freezeTrailingRows) */
5
+ effectiveRows: Record<string, unknown>[];
6
+ /** Number of pinned rows — pass to DataEditor's freezeTrailingRows */
7
+ pinnedCount: number;
8
+ pinnedPks: Set<string>;
9
+ setPinnedPks: React.Dispatch<React.SetStateAction<Set<string>>>;
10
+ }
11
+
12
+ /**
13
+ * Manages row pinning state — pinned rows placed at the end of the array
14
+ * so they can be frozen at the bottom via Glide's freezeTrailingRows.
15
+ */
16
+ export function useGlideRowPinning(
17
+ rows: Record<string, unknown>[],
18
+ pkCol: string | null,
19
+ ): UseGlideRowPinningResult {
20
+ const [pinnedPks, setPinnedPks] = useState<Set<string>>(new Set());
21
+
22
+ const effectiveRows = useMemo(() => {
23
+ if (pinnedPks.size === 0 || !pkCol) return rows;
24
+ const normal: Record<string, unknown>[] = [];
25
+ const pinned: Record<string, unknown>[] = [];
26
+ for (const row of rows) {
27
+ if (pinnedPks.has(String(row[pkCol] ?? ""))) pinned.push(row); else normal.push(row);
28
+ }
29
+ return [...normal, ...pinned];
30
+ }, [rows, pinnedPks, pkCol]);
31
+
32
+ const pinnedCount = pinnedPks.size;
33
+
34
+ return { effectiveRows, pinnedCount, pinnedPks, setPinnedPks };
35
+ }
@@ -15,8 +15,9 @@ import { SaveAsDialog } from "./save-as-dialog";
15
15
  import { EditorMobileToolbar } from "./editor-mobile-toolbar";
16
16
  import { createSqlCompletionProvider, clearCompletionCache, type SchemaInfo } from "../database/sql-completion-provider";
17
17
  import { useConnections, type Connection } from "../database/use-connections";
18
- import { DataGrid } from "../database/data-grid";
19
- import type { DbQueryResult, DbColumnInfo } from "../database/use-database";
18
+ import { GlideDataGrid } from "../database/glide-data-grid";
19
+ import type { GridColumnSchema } from "../database/glide-grid-types";
20
+ import type { DbQueryResult } from "../database/use-database";
20
21
 
21
22
  const MarkdownRenderer = lazy(() =>
22
23
  import("@/components/shared/markdown-renderer").then((m) => ({ default: m.MarkdownRenderer }))
@@ -673,7 +674,7 @@ function SqlResultPanel({ result, error, loading, connName, onClose, onOpenInTab
673
674
  : null
674
675
  ), [result]);
675
676
 
676
- const querySchema = useMemo<DbColumnInfo[]>(() => (
677
+ const querySchema = useMemo<GridColumnSchema[]>(() => (
677
678
  (result?.columns ?? []).map((c) => ({ name: c, type: "text", nullable: true, pk: false, defaultValue: null }))
678
679
  ), [result?.columns]);
679
680
 
@@ -727,8 +728,9 @@ function SqlResultPanel({ result, error, loading, connName, onClose, onOpenInTab
727
728
  </div>
728
729
  )}
729
730
  {tableData && (
730
- <DataGrid
731
- tableData={tableData} schema={querySchema} loading={false}
731
+ <GlideDataGrid
732
+ columns={tableData.columns} rows={tableData.rows} total={tableData.total} limit={tableData.limit}
733
+ schema={querySchema} loading={false}
732
734
  page={1} onPageChange={NOOP} onCellUpdate={NOOP}
733
735
  orderBy={null} orderDir="ASC" onToggleSort={NOOP}
734
736
  connectionName={connName}
@@ -67,13 +67,13 @@ export function EditorPanel({ panelId, projectName }: EditorPanelProps) {
67
67
  const isActive = tab.id === panel.activeTabId;
68
68
  if (!Component) {
69
69
  return (
70
- <div key={tab.id} className={isActive ? "h-full w-full flex items-center justify-center text-muted-foreground" : "hidden"}>
70
+ <div key={tab.id} className={isActive ? "absolute inset-0 flex items-center justify-center text-muted-foreground" : "hidden"}>
71
71
  Unknown tab type: {tab.type}
72
72
  </div>
73
73
  );
74
74
  }
75
75
  return (
76
- <div key={tab.id} className={isActive ? "h-full w-full" : "hidden"}>
76
+ <div key={tab.id} className="absolute inset-0" style={isActive ? undefined : { opacity: 0, pointerEvents: "none" }}>
77
77
  <Suspense fallback={<div className="flex items-center justify-center h-full"><Loader2 className="size-6 animate-spin text-primary" /></div>}>
78
78
  <Component metadata={tab.metadata} tabId={tab.id} />
79
79
  </Suspense>