@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.
- package/CHANGELOG.md +6 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +1 -1
- package/dist/web/assets/ai-settings-section-ysK_Eixc.js +1 -0
- package/dist/web/assets/architecture-PBZL5I3N-By4Nv3Gj.js +1 -0
- package/dist/web/assets/{audio-preview-DQbX8gfL.js → audio-preview-DISP-2AE.js} +1 -1
- package/dist/web/assets/{chat-tab-BJQT9kie.js → chat-tab-lp_mVSG-.js} +8 -8
- package/dist/web/assets/code-editor-BofKrbM8.js +8 -0
- package/dist/web/assets/{conflict-editor-BKwJLX0D.js → conflict-editor-kaAZUFD5.js} +1 -1
- package/dist/web/assets/csv-parser-Dly5nqE1.js +6 -0
- package/dist/web/assets/{csv-preview-D5lmgVEy.js → csv-preview-7TsYBQI6.js} +3 -3
- package/dist/web/assets/data-grid-overlay-editor-BjjuE4-G.js +1 -0
- package/dist/web/assets/data-grid-types-BTQHYBUh.js +1 -0
- package/dist/web/assets/database-DOWH9-Vv.js +1 -0
- package/dist/web/assets/database-viewer-P38Vxzkx.js +1 -0
- package/dist/web/assets/{diff-viewer-SAtaBwNI.js → diff-viewer-DlJfbgNJ.js} +1 -1
- package/dist/web/assets/dist-0kPgRaVx.js +1 -0
- package/dist/web/assets/{esm-nXReYVnB.js → esm-zjerHxpO.js} +1 -1
- package/dist/web/assets/{extension-webview-PiV4bKJ1.js → extension-webview-5s2MUx38.js} +1 -1
- package/dist/web/assets/gitGraph-HDMCJU4V-BLXEKVf1.js +1 -0
- package/dist/web/assets/glide-data-grid-D5D1N3L7.js +136 -0
- package/dist/web/assets/glide-data-grid-nthEL3fk.css +1 -0
- package/dist/web/assets/{image-preview-CbFFD9BS.js → image-preview-C9osjEPa.js} +1 -1
- package/dist/web/assets/index-COOnLKGB.css +2 -0
- package/dist/web/assets/index-CpcqiQOx.js +27 -0
- package/dist/web/assets/info-3K5VOQVL-CEkPcChg.js +1 -0
- package/dist/web/assets/{input-BMvRUOr7.js → input-ozrR2DAV.js} +1 -1
- package/dist/web/assets/keybindings-store-COxqSoML.js +1 -0
- package/dist/web/assets/{markdown-renderer-CHWA0KAo.js → markdown-renderer-k3EA9XmF.js} +3 -3
- package/dist/web/assets/number-overlay-editor-BoRxunFN.js +9 -0
- package/dist/web/assets/packet-RMMSAZCW-DECxYTOi.js +1 -0
- package/dist/web/assets/{pdf-preview-DQMdjqa2.js → pdf-preview-DvgyxJX7.js} +1 -1
- package/dist/web/assets/pie-UPGHQEXC-cjpNfVG5.js +1 -0
- package/dist/web/assets/{port-forwarding-tab-9BpNC9_7.js → port-forwarding-tab-CUkU6wac.js} +1 -1
- package/dist/web/assets/{postgres-viewer-Bm5T51n6.js → postgres-viewer-C1w0tqQw.js} +3 -3
- package/dist/web/assets/radar-KQ55EAFF-Dnpi068b.js +1 -0
- package/dist/web/assets/{settings-store-BHBb62gq.js → settings-store-B-OmHo3J.js} +1 -1
- package/dist/web/assets/settings-tab-D0zKyVwg.js +1 -0
- package/dist/web/assets/sql-query-editor-46hLU7MI.js +3 -0
- package/dist/web/assets/sqlite-viewer-DrLi8P6y.js +1 -0
- package/dist/web/assets/terminal-tab-DqA3fEoQ.js +1 -0
- package/dist/web/assets/treemap-KZPCXAKY-DRyb1eiw.js +1 -0
- package/dist/web/assets/{use-monaco-theme-CP-vyTF8.js → use-monaco-theme-DgzxiZS5.js} +1 -1
- package/dist/web/assets/{vendor-mermaid-CMiurk2b.js → vendor-mermaid-CCmA_6Y0.js} +3 -3
- package/dist/web/assets/{video-preview-BLI_RruT.js → video-preview-BU7tibc4.js} +1 -1
- package/dist/web/assets/x-CG-_0yIW.js +1 -0
- package/dist/web/index.html +13 -14
- package/dist/web/sw.js +1 -1
- package/package.json +2 -1
- package/src/web/components/database/data-grid.tsx +18 -2
- package/src/web/components/database/database-viewer.tsx +19 -8
- package/src/web/components/database/export-button.tsx +38 -18
- package/src/web/components/database/glide-column-search.tsx +81 -0
- package/src/web/components/database/glide-context-menu.tsx +95 -0
- package/src/web/components/database/glide-data-grid.tsx +207 -0
- package/src/web/components/database/glide-data-preview-panel.tsx +113 -0
- package/src/web/components/database/glide-grid-pagination.tsx +34 -0
- package/src/web/components/database/glide-grid-theme.ts +82 -0
- package/src/web/components/database/glide-grid-toolbar.tsx +105 -0
- package/src/web/components/database/glide-grid-types.ts +81 -0
- package/src/web/components/database/glide-header-menu.tsx +111 -0
- package/src/web/components/database/glide-save-bar.tsx +33 -0
- package/src/web/components/database/sql-query-editor.tsx +14 -4
- package/src/web/components/database/use-database.ts +10 -2
- package/src/web/components/database/use-glide-cell-content.ts +159 -0
- package/src/web/components/database/use-glide-columns.ts +69 -0
- package/src/web/components/database/use-glide-grid-actions.ts +164 -0
- package/src/web/components/database/use-glide-pending-edits.ts +72 -0
- package/src/web/components/database/use-glide-row-pinning.ts +35 -0
- package/src/web/components/database/use-glide-selection.ts +48 -0
- package/src/web/components/editor/code-editor.tsx +126 -7
- package/src/web/components/layout/editor-panel.tsx +2 -2
- package/src/web/components/sqlite/sqlite-viewer.tsx +21 -12
- package/src/web/components/sqlite/use-sqlite.ts +1 -1
- package/src/web/hooks/use-terminal.ts +1 -1
- package/src/web/index.html +1 -0
- package/test.sql +1 -0
- package/dist/web/assets/ai-settings-section-CHgpQ_OP.js +0 -1
- package/dist/web/assets/architecture-PBZL5I3N-WMbLpD5Y.js +0 -1
- package/dist/web/assets/code-editor-CeKTvfyz.js +0 -8
- package/dist/web/assets/csv-parser-DO0dz4x_.js +0 -6
- package/dist/web/assets/database-DCT0OjgQ.js +0 -1
- package/dist/web/assets/database-viewer-DixWWvjx.js +0 -5
- package/dist/web/assets/gitGraph-HDMCJU4V-BdPTuzO3.js +0 -1
- package/dist/web/assets/index-C1RBJe0a.css +0 -2
- package/dist/web/assets/index-ZFyltHwi.js +0 -27
- package/dist/web/assets/info-3K5VOQVL-MHX_1JfR.js +0 -1
- package/dist/web/assets/keybindings-store-D0C-Pq2o.js +0 -1
- package/dist/web/assets/packet-RMMSAZCW-CreFbf9A.js +0 -1
- package/dist/web/assets/pie-UPGHQEXC-CnaHXUh8.js +0 -1
- package/dist/web/assets/plus-51UQ45rf.js +0 -1
- package/dist/web/assets/radar-KQ55EAFF-UxsdRHvt.js +0 -1
- package/dist/web/assets/settings-tab-BUstSDLR.js +0 -1
- package/dist/web/assets/sql-completion-provider-tCzZfqWs.js +0 -1
- package/dist/web/assets/sql-query-editor-CMQpaOjA.js +0 -3
- package/dist/web/assets/sqlite-viewer-C7rhO4bn.js +0 -1
- package/dist/web/assets/terminal-tab-Xtj6RN0d.js +0 -1
- package/dist/web/assets/trash-2-CJYoLw7Q.js +0 -1
- package/dist/web/assets/treemap-KZPCXAKY-CBVPi4NV.js +0 -1
- package/dist/web/assets/x-BtqbfkR7.js +0 -1
- /package/dist/web/assets/{arrow-up-Dtrfv490.js → arrow-up-Rcw6_KKu.js} +0 -0
- /package/dist/web/assets/{chevron-right-BzAdxJRG.js → chevron-right-DnHIvvcy.js} +0 -0
- /package/dist/web/assets/{code-CuravVys.js → code-DGBecc50.js} +0 -0
- /package/dist/web/assets/{dist-D7KGU7Vl.js → dist-CaKCIxem.js} +0 -0
- /package/dist/web/assets/{dist-CGvx1c8C.js → dist-DGSkE2Ml.js} +0 -0
- /package/dist/web/assets/{katex-BFE6i_OH.js → katex-BuytEdO1.js} +0 -0
- /package/dist/web/assets/{lib-D_kRA9p6.js → lib-DQHnkzGy.js} +0 -0
- /package/dist/web/assets/{refresh-cw-CSFrDtiu.js → refresh-cw-LlbZDJpO.js} +0 -0
- /package/dist/web/assets/{scroll-area-BEllam7_.js → scroll-area-7H-Q_k8c.js} +0 -0
- /package/dist/web/assets/{sparkles-B0mRBy_j.js → sparkles-fWUT5Vzq.js} +0 -0
- /package/dist/web/assets/{table-Dq575bPF.js → table-tf7pRkME.js} +0 -0
- /package/dist/web/assets/{text-wrap-Cn6BNQfq.js → text-wrap-BV-R4Vvy.js} +0 -0
- /package/dist/web/assets/{use-blob-url-Hn6n1730.js → use-blob-url-e9uTXjv5.js} +0 -0
- /package/dist/web/assets/{vendor-xterm-u3AZMvTx.js → vendor-xterm-CU2c3f0A.js} +0 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { useState, useCallback, useMemo, useRef, useEffect } from "react";
|
|
2
|
+
import DataEditor, { type GridColumn, type Item } from "@glideapps/glide-data-grid";
|
|
3
|
+
import { Loader2 } from "lucide-react";
|
|
4
|
+
import type { GlideGridProps } from "./glide-grid-types";
|
|
5
|
+
import { useGlideTheme } from "./glide-grid-theme";
|
|
6
|
+
import { useGlideColumns } from "./use-glide-columns";
|
|
7
|
+
import { useGlideCellContent } from "./use-glide-cell-content";
|
|
8
|
+
import { useGlideSelection } from "./use-glide-selection";
|
|
9
|
+
import { useGlidePendingEdits } from "./use-glide-pending-edits";
|
|
10
|
+
import { useGlideRowPinning } from "./use-glide-row-pinning";
|
|
11
|
+
import { useGlideGridActions } from "./use-glide-grid-actions";
|
|
12
|
+
import { GlideHeaderMenu } from "./glide-header-menu";
|
|
13
|
+
import { GlideContextMenu } from "./glide-context-menu";
|
|
14
|
+
import { GlideGridToolbar } from "./glide-grid-toolbar";
|
|
15
|
+
import { GlideSaveBar } from "./glide-save-bar";
|
|
16
|
+
import { GlideGridPagination } from "./glide-grid-pagination";
|
|
17
|
+
import { GlideDataPreviewPanel } from "./glide-data-preview-panel";
|
|
18
|
+
|
|
19
|
+
const HEADER_ICONS: Record<string, (p: { fgColor: string }) => string> = {
|
|
20
|
+
sortAsc: (p) => `<svg viewBox="0 0 16 16" fill="${p.fgColor}"><path d="M8 4l4 6H4z"/></svg>`,
|
|
21
|
+
sortDesc: (p) => `<svg viewBox="0 0 16 16" fill="${p.fgColor}"><path d="M8 12l4-6H4z"/></svg>`,
|
|
22
|
+
headerFk: () => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none" stroke="#3b82f6" stroke-width="1.5" stroke-linecap="round"><path d="M6 4.5H4.5a2.5 2.5 0 000 5H6M10 4.5h1.5a2.5 2.5 0 010 5H10M5 7h6"/></svg>`,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Glide Data Grid wrapper for PPM database viewers.
|
|
27
|
+
* Shared by database-viewer (Postgres/MySQL) and sqlite-viewer.
|
|
28
|
+
*/
|
|
29
|
+
export function GlideDataGrid(props: GlideGridProps) {
|
|
30
|
+
const {
|
|
31
|
+
columns: rawColumnNames, rows, total, limit, schema, loading,
|
|
32
|
+
page, onPageChange, onCellUpdate, onRowDelete, onBulkDelete, onInsertRow,
|
|
33
|
+
orderBy, orderDir, onToggleSort, onClearSort, columnFilters = {}, onColumnFilter,
|
|
34
|
+
connectionId, selectedTable, selectedSchema, connectionName,
|
|
35
|
+
} = props;
|
|
36
|
+
|
|
37
|
+
const theme = useGlideTheme();
|
|
38
|
+
const gridRef = useRef<any>(null);
|
|
39
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
40
|
+
|
|
41
|
+
const columnNames = useMemo(() => {
|
|
42
|
+
const names = new Set(schema.map((s) => s.name));
|
|
43
|
+
return rawColumnNames.filter((c) => names.has(c));
|
|
44
|
+
}, [rawColumnNames, schema]);
|
|
45
|
+
|
|
46
|
+
// State
|
|
47
|
+
const [pinnedCols, setPinnedCols] = useState<Set<string>>(new Set());
|
|
48
|
+
const [colWidths, setColWidths] = useState<Map<string, number>>(new Map());
|
|
49
|
+
const [headerMenu, setHeaderMenu] = useState<{ colName: string; bounds: { x: number; y: number; width: number; height: number } } | null>(null);
|
|
50
|
+
const [contextMenu, setContextMenu] = useState<{ position: { x: number; y: number }; rowIdx: number; colIdx: number } | null>(null);
|
|
51
|
+
const [searchTerm, setSearchTerm] = useState("");
|
|
52
|
+
const [insertedRows, setInsertedRows] = useState<Record<string, unknown>[]>([]);
|
|
53
|
+
|
|
54
|
+
const pkCol = useMemo(() => {
|
|
55
|
+
return schema.find((c) => c.pk)?.name ?? schema.find((c) => c.name.toLowerCase() === "id")?.name ?? null;
|
|
56
|
+
}, [schema]);
|
|
57
|
+
|
|
58
|
+
// Row pinning (pinned rows frozen at bottom via freezeTrailingRows)
|
|
59
|
+
const { effectiveRows, pinnedCount, pinnedPks, setPinnedPks } = useGlideRowPinning(rows, pkCol);
|
|
60
|
+
|
|
61
|
+
// Merge rows: unpinned → inserted → pinned (pinned must be last for freezeTrailingRows)
|
|
62
|
+
const allRows = useMemo(() => {
|
|
63
|
+
if (pinnedCount === 0) return [...effectiveRows, ...insertedRows];
|
|
64
|
+
const unpinned = effectiveRows.slice(0, effectiveRows.length - pinnedCount);
|
|
65
|
+
const pinned = effectiveRows.slice(effectiveRows.length - pinnedCount);
|
|
66
|
+
return [...unpinned, ...insertedRows, ...pinned];
|
|
67
|
+
}, [effectiveRows, insertedRows, pinnedCount]);
|
|
68
|
+
const displayRows = useMemo(() => {
|
|
69
|
+
if (!searchTerm.trim()) return allRows;
|
|
70
|
+
const term = searchTerm.toLowerCase();
|
|
71
|
+
return allRows.filter((row) => columnNames.some((col) => String(row[col] ?? "").toLowerCase().includes(term)));
|
|
72
|
+
}, [allRows, columnNames, searchTerm]);
|
|
73
|
+
|
|
74
|
+
const frozenTrailingRows = useMemo(() => {
|
|
75
|
+
if (pinnedCount === 0 || !pkCol) return 0;
|
|
76
|
+
if (!searchTerm.trim()) return pinnedCount;
|
|
77
|
+
return displayRows.filter((row) => pinnedPks.has(String(row[pkCol] ?? ""))).length;
|
|
78
|
+
}, [pinnedCount, pkCol, searchTerm, displayRows, pinnedPks]);
|
|
79
|
+
|
|
80
|
+
// Hooks
|
|
81
|
+
const { pendingRef, addEdit, commitAll, discardAll, hasPending, pendingCount, committedRef } = useGlidePendingEdits(pkCol, onCellUpdate, onInsertRow);
|
|
82
|
+
const { columns, freezeColumns, columnOrder } = useGlideColumns(schema, columnNames, pinnedCols, colWidths, displayRows, orderBy, orderDir);
|
|
83
|
+
const { getCellContent, onCellEdited } = useGlideCellContent(displayRows, columnOrder, schema, pkCol, addEdit, pendingRef);
|
|
84
|
+
const { gridSelection, onGridSelectionChange, selectedRowIndices, clearSelection } = useGlideSelection();
|
|
85
|
+
const {
|
|
86
|
+
previewData, setPreviewData, openRowPreview, openCellPreview, openPreviewInTab,
|
|
87
|
+
handlePaste, getContextFk, isCellViewable, openFkTable,
|
|
88
|
+
} = useGlideGridActions({ displayRows, columnOrder, schema, pkCol, connectionId, connectionName, selectedTable, selectedSchema, addEdit, gridSelection, containerRef });
|
|
89
|
+
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (committedRef.current) { committedRef.current = false; discardAll(); setInsertedRows([]); }
|
|
92
|
+
}, [rows]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
93
|
+
|
|
94
|
+
const handleColumnResize = useCallback((col: GridColumn, newSize: number) => {
|
|
95
|
+
setColWidths((prev) => new Map(prev).set(col.id ?? col.title, newSize));
|
|
96
|
+
}, []);
|
|
97
|
+
const handleHeaderMenuClick = useCallback((colIdx: number, bounds: { x: number; y: number; width: number; height: number }) => {
|
|
98
|
+
const colName = columnOrder[colIdx]; if (colName) setHeaderMenu({ colName, bounds });
|
|
99
|
+
}, [columnOrder]);
|
|
100
|
+
const handleCellContextMenu = useCallback(([colIdx, rowIdx]: Item, event: { preventDefault: () => void; localEventX: number; localEventY: number; bounds: { x: number; y: number } }) => {
|
|
101
|
+
event.preventDefault();
|
|
102
|
+
setContextMenu({ position: { x: event.bounds.x + event.localEventX, y: event.bounds.y + event.localEventY }, rowIdx, colIdx });
|
|
103
|
+
}, []);
|
|
104
|
+
const togglePinColumn = useCallback((colName: string) => {
|
|
105
|
+
setPinnedCols((prev) => { const n = new Set(prev); if (n.has(colName)) n.delete(colName); else n.add(colName); return n; });
|
|
106
|
+
}, []);
|
|
107
|
+
const togglePinRow = useCallback((rowIdx: number) => {
|
|
108
|
+
if (!pkCol) return;
|
|
109
|
+
const row = displayRows[rowIdx]; if (!row) return;
|
|
110
|
+
const pk = String(row[pkCol] ?? "");
|
|
111
|
+
setPinnedPks((prev) => { const n = new Set(prev); if (n.has(pk)) n.delete(pk); else n.add(pk); return n; });
|
|
112
|
+
}, [pkCol, displayRows, setPinnedPks]);
|
|
113
|
+
const getRowThemeOverride = useCallback((rowIdx: number) => {
|
|
114
|
+
if (pinnedPks.size === 0 || !pkCol) return undefined;
|
|
115
|
+
const row = displayRows[rowIdx];
|
|
116
|
+
if (row && pinnedPks.has(String(row[pkCol] ?? ""))) return { bgCell: theme.bgCellMedium ?? theme.bgHeader };
|
|
117
|
+
return undefined;
|
|
118
|
+
}, [pinnedPks, pkCol, displayRows, theme]);
|
|
119
|
+
const handleBulkDelete = useCallback(() => {
|
|
120
|
+
if (!pkCol || !onBulkDelete) return;
|
|
121
|
+
onBulkDelete(pkCol, selectedRowIndices.map((i) => displayRows[i]?.[pkCol]).filter((v) => v != null));
|
|
122
|
+
clearSelection();
|
|
123
|
+
}, [pkCol, onBulkDelete, selectedRowIndices, displayRows, clearSelection]);
|
|
124
|
+
|
|
125
|
+
const [colSearchOpen, setColSearchOpen] = useState(false);
|
|
126
|
+
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
127
|
+
if (e.key === "Escape") { setPreviewData(null); setColSearchOpen(false); return; }
|
|
128
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "Enter" && hasPending) { e.preventDefault(); commitAll(); }
|
|
129
|
+
const tag = (e.target as HTMLElement)?.tagName;
|
|
130
|
+
if (e.key === "/" && tag !== "INPUT" && tag !== "TEXTAREA") { e.preventDefault(); setColSearchOpen(true); }
|
|
131
|
+
}, [hasPending, commitAll]);
|
|
132
|
+
const handleRowAppended = useCallback(() => {
|
|
133
|
+
if (!pkCol || !onInsertRow) return;
|
|
134
|
+
setInsertedRows((prev) => [...prev, { [pkCol]: `__new_${Date.now()}` }]);
|
|
135
|
+
}, [pkCol, onInsertRow]);
|
|
136
|
+
const handleColumnJump = useCallback((colName: string) => {
|
|
137
|
+
const idx = columnOrder.indexOf(colName);
|
|
138
|
+
if (idx >= 0 && gridRef.current?.scrollTo) gridRef.current.scrollTo(idx, 0);
|
|
139
|
+
}, [columnOrder]);
|
|
140
|
+
|
|
141
|
+
// Context menu derived state
|
|
142
|
+
const contextRow = contextMenu ? displayRows[contextMenu.rowIdx] : null;
|
|
143
|
+
const contextPk = contextRow && pkCol ? String(contextRow[pkCol] ?? "") : "";
|
|
144
|
+
const contextColName = contextMenu ? columnOrder[contextMenu.colIdx] : null;
|
|
145
|
+
const contextCellViewable = isCellViewable(contextRow ?? null, contextColName ?? null);
|
|
146
|
+
const contextFk = getContextFk(contextColName ?? null);
|
|
147
|
+
const contextCellValue = contextRow && contextColName ? contextRow[contextColName] : null;
|
|
148
|
+
|
|
149
|
+
if (!columnNames.length) {
|
|
150
|
+
return <div className="flex items-center justify-center h-full text-xs text-muted-foreground">
|
|
151
|
+
{loading ? <Loader2 className="size-4 animate-spin" /> : "Select a table"}
|
|
152
|
+
</div>;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<div ref={containerRef} className="flex flex-col h-full overflow-hidden relative" tabIndex={0} onKeyDown={handleKeyDown}>
|
|
157
|
+
<GlideGridToolbar hasSelection={selectedRowIndices.length > 0} selectedCount={selectedRowIndices.length}
|
|
158
|
+
onBulkDelete={onBulkDelete && pkCol ? handleBulkDelete : undefined}
|
|
159
|
+
onInsertRow={onInsertRow && pkCol ? handleRowAppended : undefined}
|
|
160
|
+
columns={columnNames} selectedRows={selectedRowIndices.map((i) => displayRows[i]!).filter(Boolean)} connectionName={connectionName}
|
|
161
|
+
searchTerm={searchTerm} onSearchChange={setSearchTerm} onColumnJump={handleColumnJump}
|
|
162
|
+
colSearchOpen={colSearchOpen} onColSearchChange={setColSearchOpen} />
|
|
163
|
+
|
|
164
|
+
{loading && <div className="absolute inset-0 z-30 flex items-center justify-center bg-background/60"><Loader2 className="size-5 animate-spin text-primary" /></div>}
|
|
165
|
+
|
|
166
|
+
<div className="flex-1 min-h-0">
|
|
167
|
+
<DataEditor ref={gridRef} columns={columns} rows={displayRows.length}
|
|
168
|
+
getCellContent={getCellContent} getCellsForSelection={true}
|
|
169
|
+
onCellEdited={onCellEdited} onPaste={handlePaste}
|
|
170
|
+
theme={theme} freezeColumns={freezeColumns} freezeTrailingRows={frozenTrailingRows}
|
|
171
|
+
rowMarkers={pkCol ? "checkbox-visible" : "number"}
|
|
172
|
+
gridSelection={gridSelection} onGridSelectionChange={onGridSelectionChange}
|
|
173
|
+
onColumnResize={handleColumnResize as any}
|
|
174
|
+
onHeaderMenuClick={handleHeaderMenuClick as any} onCellContextMenu={handleCellContextMenu as any}
|
|
175
|
+
getRowThemeOverride={getRowThemeOverride as any}
|
|
176
|
+
trailingRowOptions={onInsertRow && pkCol ? { sticky: true, tint: true } : undefined}
|
|
177
|
+
onRowAppended={onInsertRow ? handleRowAppended : undefined}
|
|
178
|
+
headerIcons={HEADER_ICONS} smoothScrollX smoothScrollY width="100%" height="100%" />
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
{hasPending && <GlideSaveBar pendingCount={pendingCount} onSave={commitAll} onDiscard={() => { discardAll(); setInsertedRows([]); }} />}
|
|
182
|
+
{previewData && <GlideDataPreviewPanel data={previewData} onClose={() => setPreviewData(null)} onOpenInTab={openPreviewInTab} />}
|
|
183
|
+
<GlideGridPagination total={total} page={page} totalPages={Math.ceil(total / limit) || 1} onPageChange={onPageChange} />
|
|
184
|
+
|
|
185
|
+
{headerMenu && (
|
|
186
|
+
<GlideHeaderMenu colName={headerMenu.colName} bounds={headerMenu.bounds}
|
|
187
|
+
isPinned={pinnedCols.has(headerMenu.colName)} filterValue={columnFilters[headerMenu.colName] ?? ""}
|
|
188
|
+
sortState={orderBy === headerMenu.colName ? (orderDir === "ASC" ? "asc" : "desc") : null}
|
|
189
|
+
onFilter={(val) => { if (!onColumnFilter) return; const n = { ...columnFilters }; if (val) n[headerMenu.colName] = val; else delete n[headerMenu.colName]; onColumnFilter(n); }}
|
|
190
|
+
onSort={() => { if (onToggleSort) onToggleSort(headerMenu.colName); }}
|
|
191
|
+
onClearSort={onClearSort}
|
|
192
|
+
onTogglePin={() => togglePinColumn(headerMenu.colName)} onClose={() => setHeaderMenu(null)} />
|
|
193
|
+
)}
|
|
194
|
+
|
|
195
|
+
{contextMenu && contextRow && (
|
|
196
|
+
<GlideContextMenu position={contextMenu.position} isPinned={pinnedPks.has(contextPk)}
|
|
197
|
+
onViewRow={() => openRowPreview(contextMenu.rowIdx)}
|
|
198
|
+
onViewCell={contextCellViewable ? () => openCellPreview(contextMenu.rowIdx, contextMenu.colIdx) : undefined}
|
|
199
|
+
onPinRow={() => togglePinRow(contextMenu.rowIdx)}
|
|
200
|
+
onDeleteRow={() => { if (pkCol && onRowDelete && contextRow) onRowDelete(pkCol, contextRow[pkCol]); }}
|
|
201
|
+
onOpenFkTable={contextFk && contextCellValue != null && connectionId ? () => openFkTable(contextFk, contextCellValue) : undefined}
|
|
202
|
+
fkLabel={contextFk ? `Open ${contextFk.table}.${contextFk.column}` : undefined}
|
|
203
|
+
onClose={() => setContextMenu(null)} />
|
|
204
|
+
)}
|
|
205
|
+
</div>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { useState, useCallback, useRef } from "react";
|
|
2
|
+
import { Eye, Sparkles, WrapText, ExternalLink, X, GripHorizontal } from "lucide-react";
|
|
3
|
+
import Editor from "@monaco-editor/react";
|
|
4
|
+
import { Loader2 } from "lucide-react";
|
|
5
|
+
import { useMonacoTheme } from "@/lib/use-monaco-theme";
|
|
6
|
+
|
|
7
|
+
export interface PreviewData {
|
|
8
|
+
title: string;
|
|
9
|
+
content: string;
|
|
10
|
+
language: string;
|
|
11
|
+
viewerKey: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface PreviewPanelProps {
|
|
15
|
+
data: PreviewData;
|
|
16
|
+
onClose: () => void;
|
|
17
|
+
onOpenInTab: () => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Inline preview panel for cell/row content with Monaco editor */
|
|
21
|
+
export function GlideDataPreviewPanel({ data, onClose, onOpenInTab }: PreviewPanelProps) {
|
|
22
|
+
const monacoTheme = useMonacoTheme();
|
|
23
|
+
const [wordWrap, setWordWrap] = useState(true);
|
|
24
|
+
const [displayContent, setDisplayContent] = useState(data.content);
|
|
25
|
+
const [beautified, setBeautified] = useState(false);
|
|
26
|
+
const canBeautify = data.language === "json" || data.language === "xml";
|
|
27
|
+
|
|
28
|
+
// Reset state when data changes
|
|
29
|
+
const prevKey = useRef(data.title);
|
|
30
|
+
if (prevKey.current !== data.title) {
|
|
31
|
+
prevKey.current = data.title;
|
|
32
|
+
setDisplayContent(data.content);
|
|
33
|
+
setBeautified(false);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const toggleBeautify = useCallback(() => {
|
|
37
|
+
if (beautified) {
|
|
38
|
+
setDisplayContent(data.content);
|
|
39
|
+
setBeautified(false);
|
|
40
|
+
} else if (data.language === "json") {
|
|
41
|
+
try { setDisplayContent(JSON.stringify(JSON.parse(data.content.trim()), null, 2)); setBeautified(true); } catch { /* invalid */ }
|
|
42
|
+
} else if (data.language === "xml") {
|
|
43
|
+
let depth = 0;
|
|
44
|
+
const formatted = data.content.trim().replace(/>\s*</g, ">\n<").split("\n").map((line) => {
|
|
45
|
+
const trimmed = line.trim();
|
|
46
|
+
if (trimmed.startsWith("</")) depth = Math.max(0, depth - 1);
|
|
47
|
+
const indented = " ".repeat(depth) + trimmed;
|
|
48
|
+
if (trimmed.startsWith("<") && !trimmed.startsWith("</") && !trimmed.endsWith("/>") && !trimmed.startsWith("<?")) depth++;
|
|
49
|
+
return indented;
|
|
50
|
+
}).join("\n");
|
|
51
|
+
setDisplayContent(formatted);
|
|
52
|
+
setBeautified(true);
|
|
53
|
+
}
|
|
54
|
+
}, [beautified, data.content, data.language]);
|
|
55
|
+
|
|
56
|
+
const [panelHeight, setPanelHeight] = useState(200);
|
|
57
|
+
const handleDrag = useCallback((e: React.MouseEvent) => {
|
|
58
|
+
e.preventDefault();
|
|
59
|
+
const startY = e.clientY;
|
|
60
|
+
const startH = panelHeight;
|
|
61
|
+
const onMove = (ev: MouseEvent) => setPanelHeight(Math.max(80, startH + (startY - ev.clientY)));
|
|
62
|
+
const onUp = () => { document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); };
|
|
63
|
+
document.addEventListener("mousemove", onMove);
|
|
64
|
+
document.addEventListener("mouseup", onUp);
|
|
65
|
+
}, [panelHeight]);
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div className="shrink-0 border-t border-border flex flex-col" style={{ height: panelHeight }}>
|
|
69
|
+
<div onMouseDown={handleDrag}
|
|
70
|
+
className="shrink-0 h-1.5 cursor-row-resize bg-border/50 hover:bg-primary/30 flex items-center justify-center transition-colors">
|
|
71
|
+
<GripHorizontal className="size-3 text-muted-foreground/50" />
|
|
72
|
+
</div>
|
|
73
|
+
<div className="flex items-center gap-1 px-2 py-1 bg-muted/50 border-b border-border shrink-0">
|
|
74
|
+
<Eye className="size-3 text-muted-foreground" />
|
|
75
|
+
<span className="text-xs font-medium text-foreground truncate flex-1">{data.title}</span>
|
|
76
|
+
{canBeautify && (
|
|
77
|
+
<button type="button" onClick={toggleBeautify} title={beautified ? "Raw" : "Beautify"}
|
|
78
|
+
className={`p-0.5 rounded transition-colors ${beautified ? "text-primary" : "text-muted-foreground hover:text-foreground"}`}>
|
|
79
|
+
<Sparkles className="size-3" />
|
|
80
|
+
</button>
|
|
81
|
+
)}
|
|
82
|
+
<button type="button" onClick={() => setWordWrap(!wordWrap)} title={wordWrap ? "No wrap" : "Word wrap"}
|
|
83
|
+
className={`p-0.5 rounded transition-colors ${wordWrap ? "text-primary" : "text-muted-foreground hover:text-foreground"}`}>
|
|
84
|
+
<WrapText className="size-3" />
|
|
85
|
+
</button>
|
|
86
|
+
<button type="button" onClick={onOpenInTab} title="Open in new tab"
|
|
87
|
+
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] text-muted-foreground hover:text-foreground hover:bg-muted transition-colors">
|
|
88
|
+
<ExternalLink className="size-3" />
|
|
89
|
+
<span className="hidden sm:inline">Open in Tab</span>
|
|
90
|
+
</button>
|
|
91
|
+
<button type="button" onClick={onClose} title="Close preview (Esc)"
|
|
92
|
+
className="p-0.5 rounded text-muted-foreground hover:text-foreground transition-colors">
|
|
93
|
+
<X className="size-3" />
|
|
94
|
+
</button>
|
|
95
|
+
</div>
|
|
96
|
+
<div className="flex-1 min-h-0">
|
|
97
|
+
<Editor
|
|
98
|
+
height="100%"
|
|
99
|
+
language={data.language === "plaintext" ? undefined : data.language}
|
|
100
|
+
value={displayContent}
|
|
101
|
+
theme={monacoTheme}
|
|
102
|
+
options={{
|
|
103
|
+
readOnly: true, minimap: { enabled: false }, scrollBeyondLastLine: false,
|
|
104
|
+
wordWrap: wordWrap ? "on" : "off", lineNumbers: "on", fontSize: 12,
|
|
105
|
+
folding: true, bracketPairColorization: { enabled: true },
|
|
106
|
+
domReadOnly: true, contextmenu: false, overviewRulerLanes: 0,
|
|
107
|
+
}}
|
|
108
|
+
loading={<Loader2 className="size-4 animate-spin text-muted-foreground" />}
|
|
109
|
+
/>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
interface PaginationProps {
|
|
4
|
+
total: number;
|
|
5
|
+
page: number;
|
|
6
|
+
totalPages: number;
|
|
7
|
+
onPageChange: (page: number) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Pagination footer for Glide Data Grid — row count + page nav */
|
|
11
|
+
export function GlideGridPagination({ total, page, totalPages, onPageChange }: PaginationProps) {
|
|
12
|
+
return (
|
|
13
|
+
<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">
|
|
14
|
+
<span>{total.toLocaleString()} rows</span>
|
|
15
|
+
{/* Shortcut hints — desktop only */}
|
|
16
|
+
<div className="hidden md:flex items-center gap-2 text-[10px] text-muted-foreground/50">
|
|
17
|
+
<span><kbd className="px-1 py-0.5 rounded bg-muted text-[9px]">/</kbd> columns</span>
|
|
18
|
+
<span><kbd className="px-1 py-0.5 rounded bg-muted text-[9px]">{"\u2318"}A</kbd> select all</span>
|
|
19
|
+
<span><kbd className="px-1 py-0.5 rounded bg-muted text-[9px]">{"\u2318"}C</kbd> copy</span>
|
|
20
|
+
</div>
|
|
21
|
+
<div className="flex items-center gap-2">
|
|
22
|
+
<button type="button" disabled={page <= 1} onClick={() => onPageChange(page - 1)}
|
|
23
|
+
className="p-0.5 rounded hover:bg-muted disabled:opacity-30">
|
|
24
|
+
<ChevronLeft className="size-3.5" />
|
|
25
|
+
</button>
|
|
26
|
+
<span>{page} / {totalPages}</span>
|
|
27
|
+
<button type="button" disabled={page >= totalPages} onClick={() => onPageChange(page + 1)}
|
|
28
|
+
className="p-0.5 rounded hover:bg-muted disabled:opacity-30">
|
|
29
|
+
<ChevronRight className="size-3.5" />
|
|
30
|
+
</button>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { useState, useEffect, useMemo } from "react";
|
|
2
|
+
import type { Theme } from "@glideapps/glide-data-grid";
|
|
3
|
+
import "@glideapps/glide-data-grid/dist/index.css";
|
|
4
|
+
|
|
5
|
+
/** Read a CSS custom property from :root */
|
|
6
|
+
function cssVar(name: string): string {
|
|
7
|
+
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Add alpha channel to a hex/rgb color string */
|
|
11
|
+
function withAlpha(color: string, alpha: number): string {
|
|
12
|
+
if (color.startsWith("#")) {
|
|
13
|
+
const hex = color.slice(1);
|
|
14
|
+
const r = parseInt(hex.slice(0, 2), 16);
|
|
15
|
+
const g = parseInt(hex.slice(2, 4), 16);
|
|
16
|
+
const b = parseInt(hex.slice(4, 6), 16);
|
|
17
|
+
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
18
|
+
}
|
|
19
|
+
return color;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Build Glide theme from current CSS variables */
|
|
23
|
+
function buildTheme(): Partial<Theme> {
|
|
24
|
+
const bg = cssVar("--color-background");
|
|
25
|
+
const fg = cssVar("--color-foreground");
|
|
26
|
+
const muted = cssVar("--color-muted");
|
|
27
|
+
const mutedFg = cssVar("--color-muted-foreground");
|
|
28
|
+
const primary = cssVar("--color-primary");
|
|
29
|
+
const primaryFg = cssVar("--color-primary-foreground");
|
|
30
|
+
const border = cssVar("--color-border");
|
|
31
|
+
const accent = cssVar("--color-accent");
|
|
32
|
+
const textSecondary = cssVar("--color-text-secondary");
|
|
33
|
+
const textSubtle = cssVar("--color-text-subtle");
|
|
34
|
+
const fontSans = cssVar("--font-sans") || "Geist, system-ui, sans-serif";
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
bgCell: bg,
|
|
38
|
+
bgCellMedium: muted,
|
|
39
|
+
bgHeader: muted,
|
|
40
|
+
bgHeaderHasFocus: accent,
|
|
41
|
+
bgHeaderHovered: accent,
|
|
42
|
+
bgBubble: accent,
|
|
43
|
+
bgBubbleSelected: primary,
|
|
44
|
+
textDark: fg,
|
|
45
|
+
textMedium: textSecondary,
|
|
46
|
+
textLight: textSubtle,
|
|
47
|
+
textHeader: mutedFg,
|
|
48
|
+
textGroupHeader: mutedFg,
|
|
49
|
+
textHeaderSelected: fg,
|
|
50
|
+
textBubble: fg,
|
|
51
|
+
accentColor: primary,
|
|
52
|
+
accentFg: primaryFg,
|
|
53
|
+
accentLight: withAlpha(primary, 0.12),
|
|
54
|
+
borderColor: border,
|
|
55
|
+
horizontalBorderColor: border,
|
|
56
|
+
fontFamily: fontSans,
|
|
57
|
+
baseFontStyle: "13px",
|
|
58
|
+
headerFontStyle: "600 12px",
|
|
59
|
+
editorFontSize: "13px",
|
|
60
|
+
lineHeight: 1.5,
|
|
61
|
+
cellHorizontalPadding: 8,
|
|
62
|
+
cellVerticalPadding: 4,
|
|
63
|
+
headerIconSize: 16,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Hook that returns a Glide Data Grid theme synced to PPM's dark/light mode.
|
|
69
|
+
* Watches <html> class changes via MutationObserver to rebuild on theme toggle.
|
|
70
|
+
*/
|
|
71
|
+
export function useGlideTheme(): Partial<Theme> {
|
|
72
|
+
// Bump counter on theme class change to trigger rebuild
|
|
73
|
+
const [rev, setRev] = useState(0);
|
|
74
|
+
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
const observer = new MutationObserver(() => setRev((r) => r + 1));
|
|
77
|
+
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
|
|
78
|
+
return () => observer.disconnect();
|
|
79
|
+
}, []);
|
|
80
|
+
|
|
81
|
+
return useMemo(() => buildTheme(), [rev]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
82
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { useState, useRef } from "react";
|
|
2
|
+
import { Search, X, Trash2, Plus, Columns } from "lucide-react";
|
|
3
|
+
import { ExportButton } from "./export-button";
|
|
4
|
+
import { GlideColumnSearch } from "./glide-column-search";
|
|
5
|
+
|
|
6
|
+
interface ToolbarProps {
|
|
7
|
+
hasSelection: boolean;
|
|
8
|
+
selectedCount: number;
|
|
9
|
+
onBulkDelete?: () => void;
|
|
10
|
+
onInsertRow?: () => void;
|
|
11
|
+
columns: string[];
|
|
12
|
+
selectedRows: Record<string, unknown>[];
|
|
13
|
+
connectionName?: string;
|
|
14
|
+
searchTerm: string;
|
|
15
|
+
onSearchChange: (term: string) => void;
|
|
16
|
+
onColumnJump?: (colName: string) => void;
|
|
17
|
+
/** Controlled column search open state (from parent "/" shortcut) */
|
|
18
|
+
colSearchOpen?: boolean;
|
|
19
|
+
onColSearchChange?: (open: boolean) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Toolbar above the Glide Data Grid — bulk actions, search, column jump, insert, export.
|
|
24
|
+
*/
|
|
25
|
+
export function GlideGridToolbar({
|
|
26
|
+
hasSelection, selectedCount, onBulkDelete, onInsertRow,
|
|
27
|
+
columns, selectedRows, connectionName,
|
|
28
|
+
searchTerm, onSearchChange, onColumnJump,
|
|
29
|
+
colSearchOpen: externalOpen, onColSearchChange,
|
|
30
|
+
}: ToolbarProps) {
|
|
31
|
+
const [confirmBulkDelete, setConfirmBulkDelete] = useState(false);
|
|
32
|
+
const [internalOpen, setInternalOpen] = useState(false);
|
|
33
|
+
const colSearchOpen = externalOpen ?? internalOpen;
|
|
34
|
+
const setColSearchOpen = onColSearchChange ?? setInternalOpen;
|
|
35
|
+
const colSearchBtnRef = useRef<HTMLButtonElement>(null);
|
|
36
|
+
|
|
37
|
+
const colSearchPos = colSearchBtnRef.current
|
|
38
|
+
? (() => { const r = colSearchBtnRef.current!.getBoundingClientRect(); return { x: r.left, y: r.bottom + 4 }; })()
|
|
39
|
+
: { x: 0, y: 0 };
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className="flex items-center gap-2 px-2 py-1 border-b border-border bg-background shrink-0">
|
|
43
|
+
{/* Selection info + bulk actions */}
|
|
44
|
+
{hasSelection && (
|
|
45
|
+
<div className="flex items-center gap-1.5 text-xs">
|
|
46
|
+
<span className="text-muted-foreground">{selectedCount} selected</span>
|
|
47
|
+
{onBulkDelete && (
|
|
48
|
+
confirmBulkDelete ? (
|
|
49
|
+
<span className="flex items-center gap-1">
|
|
50
|
+
<button type="button" onClick={() => { onBulkDelete(); setConfirmBulkDelete(false); }}
|
|
51
|
+
className="text-destructive text-[10px] font-medium hover:underline">
|
|
52
|
+
Delete {selectedCount}?
|
|
53
|
+
</button>
|
|
54
|
+
<button type="button" onClick={() => setConfirmBulkDelete(false)}
|
|
55
|
+
className="text-muted-foreground text-[10px] hover:underline">Cancel</button>
|
|
56
|
+
</span>
|
|
57
|
+
) : (
|
|
58
|
+
<button type="button" onClick={() => setConfirmBulkDelete(true)}
|
|
59
|
+
className="p-0.5 text-muted-foreground hover:text-destructive">
|
|
60
|
+
<Trash2 className="size-3" />
|
|
61
|
+
</button>
|
|
62
|
+
)
|
|
63
|
+
)}
|
|
64
|
+
<ExportButton columns={columns} rows={selectedRows} filename={`${connectionName ?? "db"}-selected`} />
|
|
65
|
+
</div>
|
|
66
|
+
)}
|
|
67
|
+
|
|
68
|
+
<div className="flex-1" />
|
|
69
|
+
|
|
70
|
+
{/* Client-side search */}
|
|
71
|
+
<div className="flex items-center gap-1 text-xs">
|
|
72
|
+
<Search className="size-3 text-muted-foreground" />
|
|
73
|
+
<input value={searchTerm} onChange={(e) => onSearchChange(e.target.value)}
|
|
74
|
+
placeholder="Search page…"
|
|
75
|
+
className="w-24 bg-transparent outline-none text-foreground placeholder:text-muted-foreground text-xs" />
|
|
76
|
+
{searchTerm && (
|
|
77
|
+
<button type="button" onClick={() => onSearchChange("")}
|
|
78
|
+
className="text-muted-foreground hover:text-foreground">
|
|
79
|
+
<X className="size-3" />
|
|
80
|
+
</button>
|
|
81
|
+
)}
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
{/* Column jump */}
|
|
85
|
+
{onColumnJump && (
|
|
86
|
+
<button ref={colSearchBtnRef} type="button" onClick={() => setColSearchOpen(!colSearchOpen)}
|
|
87
|
+
className="p-0.5 rounded text-muted-foreground hover:text-foreground transition-colors" title="Jump to column (/)">
|
|
88
|
+
<Columns className="size-3.5" />
|
|
89
|
+
</button>
|
|
90
|
+
)}
|
|
91
|
+
|
|
92
|
+
{/* Insert row */}
|
|
93
|
+
{onInsertRow && (
|
|
94
|
+
<button type="button" onClick={onInsertRow}
|
|
95
|
+
className="p-0.5 rounded text-muted-foreground hover:text-primary transition-colors" title="Insert row">
|
|
96
|
+
<Plus className="size-3.5" />
|
|
97
|
+
</button>
|
|
98
|
+
)}
|
|
99
|
+
|
|
100
|
+
{colSearchOpen && onColumnJump && (
|
|
101
|
+
<GlideColumnSearch columns={columns} onSelect={onColumnJump} onClose={() => setColSearchOpen(false)} anchorRect={colSearchPos} />
|
|
102
|
+
)}
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** Shared types for Glide Data Grid wrapper used by database-viewer and sqlite-viewer */
|
|
2
|
+
|
|
3
|
+
/** Unified column schema — superset of DbColumnInfo and sqlite ColumnInfo */
|
|
4
|
+
export interface GridColumnSchema {
|
|
5
|
+
name: string;
|
|
6
|
+
type: string;
|
|
7
|
+
nullable: boolean;
|
|
8
|
+
pk: boolean;
|
|
9
|
+
defaultValue?: string | null;
|
|
10
|
+
fk?: { table: string; column: string } | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Unified props interface for the Glide Data Grid wrapper component */
|
|
14
|
+
export interface GlideGridProps {
|
|
15
|
+
/** Column names in display order */
|
|
16
|
+
columns: string[];
|
|
17
|
+
/** Row data array (current page) */
|
|
18
|
+
rows: Record<string, unknown>[];
|
|
19
|
+
/** Total row count across all pages */
|
|
20
|
+
total: number;
|
|
21
|
+
/** Rows per page */
|
|
22
|
+
limit: number;
|
|
23
|
+
/** Column schema metadata */
|
|
24
|
+
schema: GridColumnSchema[];
|
|
25
|
+
/** Whether data is currently loading */
|
|
26
|
+
loading: boolean;
|
|
27
|
+
/** Current page number (1-based) */
|
|
28
|
+
page: number;
|
|
29
|
+
onPageChange: (page: number) => void;
|
|
30
|
+
/** Cell edit: (pkColumn, pkValue, editedColumn, newValue) */
|
|
31
|
+
onCellUpdate: (pkCol: string, pkVal: unknown, col: string, val: unknown) => void;
|
|
32
|
+
onRowDelete?: (pkCol: string, pkVal: unknown) => void;
|
|
33
|
+
onBulkDelete?: (pkCol: string, pkValues: unknown[]) => void;
|
|
34
|
+
onInsertRow?: (values: Record<string, unknown>) => Promise<void>;
|
|
35
|
+
/** Current sort column */
|
|
36
|
+
orderBy?: string | null;
|
|
37
|
+
orderDir?: "ASC" | "DESC";
|
|
38
|
+
onToggleSort?: (column: string) => void;
|
|
39
|
+
onClearSort?: () => void;
|
|
40
|
+
/** Per-column ILIKE filters (server-side) */
|
|
41
|
+
columnFilters?: Record<string, string>;
|
|
42
|
+
onColumnFilter?: (filters: Record<string, string>) => void;
|
|
43
|
+
/** Metadata for export/viewer features */
|
|
44
|
+
connectionId?: number;
|
|
45
|
+
selectedTable?: string | null;
|
|
46
|
+
selectedSchema?: string;
|
|
47
|
+
connectionName?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Threshold in bytes for showing cell viewer (eye button) */
|
|
51
|
+
export const LARGE_THRESHOLD = 200;
|
|
52
|
+
|
|
53
|
+
/** Check if a cell value needs the large data viewer */
|
|
54
|
+
export function needsViewer(val: unknown): boolean {
|
|
55
|
+
if (val == null) return false;
|
|
56
|
+
if (typeof val === "object") return true;
|
|
57
|
+
const s = String(val);
|
|
58
|
+
if (s.length >= LARGE_THRESHOLD) return true;
|
|
59
|
+
const trimmed = s.trimStart();
|
|
60
|
+
if ((trimmed[0] === "{" || trimmed[0] === "[") && (trimmed.endsWith("}") || trimmed.endsWith("]"))) return true;
|
|
61
|
+
if (trimmed.startsWith("<?xml") || (trimmed.startsWith("<") && trimmed.endsWith(">"))) return true;
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Format cell value for display — JSON-stringify objects, otherwise String() */
|
|
66
|
+
export function formatCellValue(val: unknown): string {
|
|
67
|
+
if (val == null) return "NULL";
|
|
68
|
+
if (typeof val === "object") return JSON.stringify(val);
|
|
69
|
+
return String(val);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Detect language from cell content for syntax highlighting in viewer */
|
|
73
|
+
export function detectLang(text: string): string {
|
|
74
|
+
const t = text.trimStart();
|
|
75
|
+
if (t[0] === "{" || t[0] === "[") {
|
|
76
|
+
try { JSON.parse(t); return "json"; } catch { /* not json */ }
|
|
77
|
+
}
|
|
78
|
+
if (t.startsWith("<?xml") || (t.startsWith("<") && /<\/\w+>/.test(t))) return "xml";
|
|
79
|
+
if (t.startsWith("---") || /^\w+:\s/m.test(t)) return "yaml";
|
|
80
|
+
return "plaintext";
|
|
81
|
+
}
|