@hienlh/ppm 0.13.7 → 0.13.9
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 +16 -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-CLNBWLS4.js +1 -0
- package/dist/web/assets/{api-settings-t7Leca7J.js → api-settings-D0_eiIYv.js} +1 -1
- package/dist/web/assets/architecture-PBZL5I3N-WMbLpD5Y.js +1 -0
- package/dist/web/assets/{audio-preview-BR7DoH0l.js → audio-preview-C1p-Q5XZ.js} +1 -1
- package/dist/web/assets/{chat-tab-8Bvn7PHt.js → chat-tab-BSJUkgxB.js} +9 -9
- package/dist/web/assets/code-editor-rNw5_pXh.js +8 -0
- package/dist/web/assets/{conflict-editor-C-lUw4gv.js → conflict-editor-Dcn3HuLD.js} +1 -1
- package/dist/web/assets/{csv-preview-C9qGhDlb.js → csv-preview-D5lmgVEy.js} +1 -1
- package/dist/web/assets/data-grid-nZfSIop5.js +5 -0
- package/dist/web/assets/database-DOWH9-Vv.js +1 -0
- package/dist/web/assets/database-viewer-CNoq5Uxp.js +1 -0
- package/dist/web/assets/{diff-viewer-I8qs3Nb-.js → diff-viewer-NMLD4V8q.js} +1 -1
- package/dist/web/assets/{esm-B3je8j5P.js → esm-nXReYVnB.js} +1 -1
- package/dist/web/assets/{extension-webview-Dt9bKs0C.js → extension-webview-DW2dBswj.js} +1 -1
- package/dist/web/assets/{file-store-BgZggznw.js → file-store-BrbCNyLm.js} +1 -1
- package/dist/web/assets/gitGraph-HDMCJU4V-BdPTuzO3.js +1 -0
- package/dist/web/assets/{image-preview-C-w-CK5s.js → image-preview-Dqp1KSus.js} +1 -1
- package/dist/web/assets/index-CoMWx5VS.js +27 -0
- package/dist/web/assets/index-Dzb3OtrX.css +2 -0
- package/dist/web/assets/info-3K5VOQVL-MHX_1JfR.js +1 -0
- package/dist/web/assets/{input-bGJExpJZ.js → input-DYWhyaze.js} +1 -1
- package/dist/web/assets/keybindings-store-B7nlHmDh.js +1 -0
- package/dist/web/assets/{markdown-renderer-ySnJPmc1.js → markdown-renderer-DNIXdY0d.js} +3 -3
- package/dist/web/assets/packet-RMMSAZCW-CreFbf9A.js +1 -0
- package/dist/web/assets/{pdf-preview-BoQTv6B2.js → pdf-preview-ChC1gaaZ.js} +1 -1
- package/dist/web/assets/pie-UPGHQEXC-CnaHXUh8.js +1 -0
- package/dist/web/assets/port-forwarding-tab-dLhH_g2l.js +1 -0
- package/dist/web/assets/{postgres-viewer-CBntLqXY.js → postgres-viewer-De0pzd1C.js} +3 -3
- package/dist/web/assets/radar-KQ55EAFF-UxsdRHvt.js +1 -0
- package/dist/web/assets/{scroll-area-D0EQpAH2.js → scroll-area-BEllam7_.js} +1 -1
- package/dist/web/assets/{settings-store-CdcSAgEZ.js → settings-store-BHBb62gq.js} +2 -2
- package/dist/web/assets/settings-tab-Mrs9uzCZ.js +1 -0
- package/dist/web/assets/sparkles-B0mRBy_j.js +1 -0
- package/dist/web/assets/{sql-query-editor-vpD0I0KG.js → sql-query-editor-CMQpaOjA.js} +1 -1
- package/dist/web/assets/sqlite-viewer-BqtIjvil.js +1 -0
- package/dist/web/assets/tab-store-0rGchMXr.js +1 -0
- package/dist/web/assets/terminal-tab-CeHEtoE2.js +1 -0
- package/dist/web/assets/treemap-KZPCXAKY-CBVPi4NV.js +1 -0
- package/dist/web/assets/{use-blob-url-BgxxT-n_.js → use-blob-url-Hn6n1730.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-dtPsv6sh.js → use-monaco-theme-CP-vyTF8.js} +1 -1
- package/dist/web/assets/{vendor-mermaid-DCxaaPi4.js → vendor-mermaid-CMiurk2b.js} +2 -2
- package/dist/web/assets/{video-preview-DxTnfuSQ.js → video-preview-CHPVrMtx.js} +1 -1
- package/dist/web/assets/x-OGGXhtlb.js +1 -0
- package/dist/web/index.html +18 -18
- package/dist/web/sw.js +1 -1
- package/package.json +2 -1
- package/src/index.ts +0 -0
- package/src/server/ws/terminal.ts +4 -0
- package/src/services/terminal.service.ts +4 -1
- package/src/web/components/database/data-grid.tsx +133 -17
- package/src/web/components/database/glide-grid-theme.ts +82 -0
- package/src/web/components/database/glide-grid-types.ts +79 -0
- package/src/web/components/database/use-glide-cell-content.ts +124 -0
- package/src/web/components/database/use-glide-columns.ts +61 -0
- package/src/web/components/database/use-glide-selection.ts +48 -0
- package/src/web/components/editor/code-editor.tsx +126 -9
- package/src/web/components/layout/draggable-tab.tsx +1 -0
- package/src/web/components/terminal/terminal-tab.tsx +19 -8
- package/src/web/hooks/use-terminal.ts +22 -2
- package/src/web/index.html +1 -0
- package/src/web/stores/panel-utils.ts +1 -0
- package/test.sql +1 -0
- package/bun.lock +0 -2062
- package/bunfig.toml +0 -2
- package/dist/web/assets/ai-settings-section-DeW4WN43.js +0 -1
- package/dist/web/assets/architecture-PBZL5I3N-Dy3PgD6O.js +0 -1
- package/dist/web/assets/code-editor-B-F5VdzM.js +0 -8
- package/dist/web/assets/database-DCT0OjgQ.js +0 -1
- package/dist/web/assets/database-viewer-mLY7oMC_.js +0 -2
- package/dist/web/assets/gitGraph-HDMCJU4V-Bu1SIFFq.js +0 -1
- package/dist/web/assets/index-CEI0tfaL.css +0 -2
- package/dist/web/assets/index-CFz4k7zO.js +0 -27
- package/dist/web/assets/info-3K5VOQVL-DzfAxmVd.js +0 -1
- package/dist/web/assets/keybindings-store-CWX97luK.js +0 -1
- package/dist/web/assets/packet-RMMSAZCW-DpzHf4xp.js +0 -1
- package/dist/web/assets/pie-UPGHQEXC-BpzFCKJ8.js +0 -1
- package/dist/web/assets/plus-51UQ45rf.js +0 -1
- package/dist/web/assets/port-forwarding-tab-CjlX-0D2.js +0 -1
- package/dist/web/assets/radar-KQ55EAFF-DAxWKxM4.js +0 -1
- package/dist/web/assets/settings-tab-Djw-bqxG.js +0 -1
- package/dist/web/assets/sqlite-viewer-CjtNrQ7C.js +0 -1
- package/dist/web/assets/tab-store-Jvy1eZGM.js +0 -1
- package/dist/web/assets/terminal-tab-bKLoy06f.js +0 -1
- package/dist/web/assets/treemap-KZPCXAKY-D6dgXbAe.js +0 -1
- package/dist/web/assets/x-BtqbfkR7.js +0 -1
- /package/dist/web/assets/{api-client-r4nyVy7H.js → api-client-Dvzcc_EO.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/{csv-parser-DxVplKKB.js → csv-parser-DO0dz4x_.js} +0 -0
- /package/dist/web/assets/{dist-BqoEabX7.js → dist-CGvx1c8C.js} +0 -0
- /package/dist/web/assets/{katex-bpagxk3Z.js → katex-BFE6i_OH.js} +0 -0
- /package/dist/web/assets/{lib-BqkcKGFq.js → lib-D_kRA9p6.js} +0 -0
- /package/dist/web/assets/{react-BkWDCPD7.js → react-GqWghJ-L.js} +0 -0
- /package/dist/web/assets/{sql-completion-provider-EzHOQLfo.js → sql-completion-provider-tCzZfqWs.js} +0 -0
- /package/dist/web/assets/{table-DbSviOmw.js → table-Dq575bPF.js} +0 -0
- /package/dist/web/assets/{text-wrap-DzvCTq_i.js → text-wrap-Cn6BNQfq.js} +0 -0
- /package/dist/web/assets/{trash-2-BgDIBl6f.js → trash-2-CJYoLw7Q.js} +0 -0
- /package/dist/web/assets/{utils-ChWX7pZv.js → utils-CTg5uAYR.js} +0 -0
- /package/dist/web/assets/{vendor-xterm-D7SePDJp.js → vendor-xterm-u3AZMvTx.js} +0 -0
|
@@ -0,0 +1,124 @@
|
|
|
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
|
+
|
|
6
|
+
/** Map DB type string to Glide cell kind */
|
|
7
|
+
function dbTypeToKind(type: string): GridCellKind {
|
|
8
|
+
const t = type.toLowerCase();
|
|
9
|
+
if (/^(int|serial|bigint|smallint|float|double|decimal|numeric|real|money)/.test(t)) {
|
|
10
|
+
return GridCellKind.Number;
|
|
11
|
+
}
|
|
12
|
+
if (/^bool/.test(t)) return GridCellKind.Boolean;
|
|
13
|
+
return GridCellKind.Text;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Truncate display string for canvas rendering performance */
|
|
17
|
+
function truncateDisplay(val: string, max = 200): string {
|
|
18
|
+
return val.length > max ? val.slice(0, max) + "…" : val;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface UseGlideCellContentResult {
|
|
22
|
+
getCellContent: (cell: Item) => GridCell;
|
|
23
|
+
onCellEdited: (cell: Item, newValue: EditableGridCell) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Provides getCellContent and onCellEdited callbacks for Glide Data Grid.
|
|
28
|
+
* Uses refs for rows/columnOrder to avoid stale closures in canvas render loop.
|
|
29
|
+
*/
|
|
30
|
+
export function useGlideCellContent(
|
|
31
|
+
rows: Record<string, unknown>[],
|
|
32
|
+
columnOrder: string[],
|
|
33
|
+
schema: GridColumnSchema[],
|
|
34
|
+
pkCol: string | null,
|
|
35
|
+
onCellUpdate: (pkCol: string, pkVal: unknown, col: string, val: unknown) => void,
|
|
36
|
+
): UseGlideCellContentResult {
|
|
37
|
+
// Use refs to avoid stale data in canvas callbacks
|
|
38
|
+
const rowsRef = useRef(rows);
|
|
39
|
+
rowsRef.current = rows;
|
|
40
|
+
const colOrderRef = useRef(columnOrder);
|
|
41
|
+
colOrderRef.current = columnOrder;
|
|
42
|
+
|
|
43
|
+
const schemaMap = useRef(new Map<string, GridColumnSchema>());
|
|
44
|
+
schemaMap.current = new Map(schema.map((s) => [s.name, s]));
|
|
45
|
+
|
|
46
|
+
const getCellContent = useCallback(([colIdx, rowIdx]: Item): GridCell => {
|
|
47
|
+
const colName = colOrderRef.current[colIdx];
|
|
48
|
+
const row = rowsRef.current[rowIdx];
|
|
49
|
+
if (!colName || !row) {
|
|
50
|
+
return { kind: GridCellKind.Text, data: "", displayData: "", allowOverlay: false };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const val = row[colName];
|
|
54
|
+
const colSchema = schemaMap.current.get(colName);
|
|
55
|
+
const kind = colSchema ? dbTypeToKind(colSchema.type) : GridCellKind.Text;
|
|
56
|
+
const isPk = colSchema?.pk ?? false;
|
|
57
|
+
|
|
58
|
+
// NULL values — styled as italic gray text
|
|
59
|
+
if (val == null) {
|
|
60
|
+
return {
|
|
61
|
+
kind: GridCellKind.Text,
|
|
62
|
+
data: "",
|
|
63
|
+
displayData: "NULL",
|
|
64
|
+
allowOverlay: !isPk,
|
|
65
|
+
readonly: isPk,
|
|
66
|
+
themeOverride: { textDark: "#6b7280" },
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Number cells
|
|
71
|
+
if (kind === GridCellKind.Number && typeof val === "number") {
|
|
72
|
+
return {
|
|
73
|
+
kind: GridCellKind.Number,
|
|
74
|
+
data: val,
|
|
75
|
+
displayData: String(val),
|
|
76
|
+
allowOverlay: !isPk,
|
|
77
|
+
readonly: isPk,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Boolean cells — display only, edit via text overlay
|
|
82
|
+
if (kind === GridCellKind.Boolean && typeof val === "boolean") {
|
|
83
|
+
return {
|
|
84
|
+
kind: GridCellKind.Boolean,
|
|
85
|
+
data: val,
|
|
86
|
+
readonly: isPk,
|
|
87
|
+
allowOverlay: false,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Default: Text cell
|
|
92
|
+
const strVal = formatCellValue(val);
|
|
93
|
+
return {
|
|
94
|
+
kind: GridCellKind.Text,
|
|
95
|
+
data: strVal,
|
|
96
|
+
displayData: truncateDisplay(strVal),
|
|
97
|
+
allowOverlay: !isPk,
|
|
98
|
+
readonly: isPk,
|
|
99
|
+
};
|
|
100
|
+
}, []); // stable — reads from refs
|
|
101
|
+
|
|
102
|
+
const onCellEdited = useCallback(([colIdx, rowIdx]: Item, newValue: EditableGridCell) => {
|
|
103
|
+
if (!pkCol) return;
|
|
104
|
+
const colName = colOrderRef.current[colIdx];
|
|
105
|
+
const row = rowsRef.current[rowIdx];
|
|
106
|
+
if (!colName || !row) return;
|
|
107
|
+
|
|
108
|
+
const pkVal = row[pkCol];
|
|
109
|
+
let parsed: unknown;
|
|
110
|
+
if (newValue.kind === GridCellKind.Text) {
|
|
111
|
+
parsed = newValue.data === "" ? null : newValue.data;
|
|
112
|
+
} else if (newValue.kind === GridCellKind.Number) {
|
|
113
|
+
parsed = newValue.data;
|
|
114
|
+
} else if (newValue.kind === GridCellKind.Boolean) {
|
|
115
|
+
parsed = newValue.data;
|
|
116
|
+
} else {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
onCellUpdate(pkCol, pkVal, colName, parsed);
|
|
121
|
+
}, [pkCol, onCellUpdate]);
|
|
122
|
+
|
|
123
|
+
return { getCellContent, onCellEdited };
|
|
124
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
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
|
+
/**
|
|
15
|
+
* Build Glide Data Grid column definitions from schema.
|
|
16
|
+
* Reorders columns: pinned first, then unpinned.
|
|
17
|
+
*/
|
|
18
|
+
export function useGlideColumns(
|
|
19
|
+
schema: GridColumnSchema[],
|
|
20
|
+
columnNames: string[],
|
|
21
|
+
pinnedCols: Set<string>,
|
|
22
|
+
colWidths: Map<string, number>,
|
|
23
|
+
orderBy?: string | null,
|
|
24
|
+
orderDir?: "ASC" | "DESC",
|
|
25
|
+
): UseGlideColumnsResult {
|
|
26
|
+
return useMemo(() => {
|
|
27
|
+
// Reorder: pinned first, then rest (preserve original order within each group)
|
|
28
|
+
const pinned = columnNames.filter((c) => pinnedCols.has(c));
|
|
29
|
+
const unpinned = columnNames.filter((c) => !pinnedCols.has(c));
|
|
30
|
+
const ordered = [...pinned, ...unpinned];
|
|
31
|
+
|
|
32
|
+
const schemaMap = new Map(schema.map((s) => [s.name, s]));
|
|
33
|
+
|
|
34
|
+
const columns: GridColumn[] = ordered.map((name) => {
|
|
35
|
+
const col = schemaMap.get(name);
|
|
36
|
+
const isPk = col?.pk ?? false;
|
|
37
|
+
|
|
38
|
+
// Determine sort icon
|
|
39
|
+
let icon: string | undefined;
|
|
40
|
+
if (orderBy === name) {
|
|
41
|
+
icon = orderDir === "ASC" ? "sortAsc" : "sortDesc";
|
|
42
|
+
} else if (isPk) {
|
|
43
|
+
icon = "headerRowID";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
title: name,
|
|
48
|
+
id: name,
|
|
49
|
+
width: colWidths.get(name) ?? 150,
|
|
50
|
+
hasMenu: true,
|
|
51
|
+
icon,
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
columns,
|
|
57
|
+
freezeColumns: pinned.length,
|
|
58
|
+
columnOrder: ordered,
|
|
59
|
+
};
|
|
60
|
+
}, [schema, columnNames, pinnedCols, colWidths, orderBy, orderDir]);
|
|
61
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { useState, useCallback, useMemo } from "react";
|
|
2
|
+
import { CompactSelection, type GridSelection } from "@glideapps/glide-data-grid";
|
|
3
|
+
|
|
4
|
+
const EMPTY_SELECTION: GridSelection = {
|
|
5
|
+
columns: CompactSelection.empty(),
|
|
6
|
+
rows: CompactSelection.empty(),
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
interface UseGlideSelectionResult {
|
|
10
|
+
gridSelection: GridSelection;
|
|
11
|
+
onGridSelectionChange: (newSel: GridSelection) => void;
|
|
12
|
+
/** Array of selected row indices (derived from CompactSelection) */
|
|
13
|
+
selectedRowIndices: number[];
|
|
14
|
+
clearSelection: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Manages controlled selection state for Glide Data Grid.
|
|
19
|
+
* Provides row indices array for bulk operations (delete, export).
|
|
20
|
+
*/
|
|
21
|
+
export function useGlideSelection(): UseGlideSelectionResult {
|
|
22
|
+
const [gridSelection, setGridSelection] = useState<GridSelection>(EMPTY_SELECTION);
|
|
23
|
+
|
|
24
|
+
const onGridSelectionChange = useCallback((newSel: GridSelection) => {
|
|
25
|
+
setGridSelection(newSel);
|
|
26
|
+
}, []);
|
|
27
|
+
|
|
28
|
+
const selectedRowIndices = useMemo(() => {
|
|
29
|
+
const indices: number[] = [];
|
|
30
|
+
if (gridSelection.rows) {
|
|
31
|
+
for (const range of gridSelection.rows) {
|
|
32
|
+
// CompactSelection stores [start, end) ranges
|
|
33
|
+
if (Array.isArray(range)) {
|
|
34
|
+
for (let i = range[0]; i < range[1]; i++) indices.push(i);
|
|
35
|
+
} else {
|
|
36
|
+
indices.push(range);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return indices;
|
|
41
|
+
}, [gridSelection.rows]);
|
|
42
|
+
|
|
43
|
+
const clearSelection = useCallback(() => {
|
|
44
|
+
setGridSelection(EMPTY_SELECTION);
|
|
45
|
+
}, []);
|
|
46
|
+
|
|
47
|
+
return { gridSelection, onGridSelectionChange, selectedRowIndices, clearSelection };
|
|
48
|
+
}
|
|
@@ -8,13 +8,15 @@ import { usePanelStore } from "@/stores/panel-store";
|
|
|
8
8
|
import { useSettingsStore } from "@/stores/settings-store";
|
|
9
9
|
import { basename } from "@/lib/utils";
|
|
10
10
|
import { useMonacoTheme } from "@/lib/use-monaco-theme";
|
|
11
|
-
import { Loader2, FileWarning, Play, Database } from "lucide-react";
|
|
11
|
+
import { Loader2, FileWarning, Play, Database, ExternalLink, X, GripHorizontal } from "lucide-react";
|
|
12
12
|
import { EditorBreadcrumb } from "./editor-breadcrumb";
|
|
13
13
|
import { EditorToolbar } from "./editor-toolbar";
|
|
14
14
|
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
20
|
|
|
19
21
|
const MarkdownRenderer = lazy(() =>
|
|
20
22
|
import("@/components/shared/markdown-renderer").then((m) => ({ default: m.MarkdownRenderer }))
|
|
@@ -170,18 +172,37 @@ export const CodeEditor = memo(function CodeEditor({ metadata, tabId }: CodeEdit
|
|
|
170
172
|
return () => { completionDisposable.current?.dispose(); };
|
|
171
173
|
}, [sqlSchemaInfo]);
|
|
172
174
|
|
|
173
|
-
// Run in
|
|
175
|
+
// Run SQL inline — execute query and show results in bottom panel
|
|
174
176
|
const openTab = useTabStore((s) => s.openTab);
|
|
175
|
-
const
|
|
177
|
+
const [sqlResult, setSqlResult] = useState<DbQueryResult | null>(null);
|
|
178
|
+
const [sqlError, setSqlError] = useState<string | null>(null);
|
|
179
|
+
const [sqlLoading, setSqlLoading] = useState(false);
|
|
180
|
+
const [sqlResultSql, setSqlResultSql] = useState<string>("");
|
|
181
|
+
const runSqlInViewer = useCallback(async (sqlText: string) => {
|
|
176
182
|
if (!selectedSqlConn) return;
|
|
183
|
+
setSqlLoading(true);
|
|
184
|
+
setSqlError(null);
|
|
185
|
+
setSqlResultSql(sqlText);
|
|
186
|
+
try {
|
|
187
|
+
const result = await api.post<DbQueryResult>(`/api/db/connections/${selectedSqlConn.id}/query`, { sql: sqlText });
|
|
188
|
+
setSqlResult(result);
|
|
189
|
+
} catch (e) {
|
|
190
|
+
setSqlError((e as Error).message);
|
|
191
|
+
setSqlResult(null);
|
|
192
|
+
} finally {
|
|
193
|
+
setSqlLoading(false);
|
|
194
|
+
}
|
|
195
|
+
}, [selectedSqlConn]);
|
|
196
|
+
const openSqlResultInTab = useCallback(() => {
|
|
197
|
+
if (!selectedSqlConn || !sqlResultSql) return;
|
|
177
198
|
openTab({
|
|
178
199
|
type: "database",
|
|
179
200
|
title: `${selectedSqlConn.name} · Query`,
|
|
180
201
|
projectId: null,
|
|
181
202
|
closable: true,
|
|
182
|
-
metadata: { connectionId: selectedSqlConn.id, connectionName: selectedSqlConn.name, dbType: selectedSqlConn.type, initialSql:
|
|
203
|
+
metadata: { connectionId: selectedSqlConn.id, connectionName: selectedSqlConn.name, dbType: selectedSqlConn.type, initialSql: sqlResultSql },
|
|
183
204
|
});
|
|
184
|
-
}, [selectedSqlConn, openTab]);
|
|
205
|
+
}, [selectedSqlConn, openTab, sqlResultSql]);
|
|
185
206
|
|
|
186
207
|
const handleRunInDbViewer = useCallback(() => {
|
|
187
208
|
if (!editorRef.current || !selectedSqlConn) return;
|
|
@@ -299,9 +320,9 @@ export const CodeEditor = memo(function CodeEditor({ metadata, tabId }: CodeEdit
|
|
|
299
320
|
return () => window.removeEventListener("file:changed", handler);
|
|
300
321
|
}, [filePath, projectName, isExternalFile, inlineContent, isUntitled]);
|
|
301
322
|
|
|
302
|
-
// Update tab title unsaved indicator
|
|
323
|
+
// Update tab title unsaved indicator (skip for inline content — title set by caller)
|
|
303
324
|
useEffect(() => {
|
|
304
|
-
if (!ownTab) return;
|
|
325
|
+
if (!ownTab || inlineContent != null) return;
|
|
305
326
|
const baseName = isUntitled
|
|
306
327
|
? `Untitled-${metadata?.untitledNumber ?? 1}`
|
|
307
328
|
: (filePath ? basename(filePath) : "Untitled");
|
|
@@ -520,7 +541,7 @@ export const CodeEditor = memo(function CodeEditor({ metadata, tabId }: CodeEdit
|
|
|
520
541
|
onClick={handleRunInDbViewer}
|
|
521
542
|
disabled={!selectedSqlConn}
|
|
522
543
|
className="p-0.5 rounded text-muted-foreground hover:text-primary disabled:opacity-30 transition-colors"
|
|
523
|
-
title="Run
|
|
544
|
+
title="Run SQL"
|
|
524
545
|
>
|
|
525
546
|
<Play className="size-3.5" />
|
|
526
547
|
</button>
|
|
@@ -583,7 +604,7 @@ export const CodeEditor = memo(function CodeEditor({ metadata, tabId }: CodeEdit
|
|
|
583
604
|
) : isMarkdown && mdMode === "preview" ? (
|
|
584
605
|
<MarkdownPreview content={content ?? ""} />
|
|
585
606
|
) : (
|
|
586
|
-
<div className="flex-1 overflow-hidden">
|
|
607
|
+
<div className="flex-1 overflow-hidden min-h-0">
|
|
587
608
|
<Editor
|
|
588
609
|
height="100%"
|
|
589
610
|
language={inlineLanguage ?? getMonacoLanguage(filePath ?? "")}
|
|
@@ -608,6 +629,16 @@ export const CodeEditor = memo(function CodeEditor({ metadata, tabId }: CodeEdit
|
|
|
608
629
|
</div>
|
|
609
630
|
)}
|
|
610
631
|
|
|
632
|
+
{/* Inline SQL result panel */}
|
|
633
|
+
{isSql && (sqlResult || sqlError || sqlLoading) && (
|
|
634
|
+
<SqlResultPanel
|
|
635
|
+
result={sqlResult} error={sqlError} loading={sqlLoading}
|
|
636
|
+
connName={selectedSqlConn?.name}
|
|
637
|
+
onClose={() => { setSqlResult(null); setSqlError(null); setSqlLoading(false); }}
|
|
638
|
+
onOpenInTab={openSqlResultInTab}
|
|
639
|
+
/>
|
|
640
|
+
)}
|
|
641
|
+
|
|
611
642
|
{/* Mobile toolbar — bottom, like terminal */}
|
|
612
643
|
{isMobile && <EditorMobileToolbar editorRef={editorRef} readOnly={inlineContent != null} />}
|
|
613
644
|
|
|
@@ -625,6 +656,92 @@ export const CodeEditor = memo(function CodeEditor({ metadata, tabId }: CodeEdit
|
|
|
625
656
|
);
|
|
626
657
|
});
|
|
627
658
|
|
|
659
|
+
const NOOP = () => {};
|
|
660
|
+
|
|
661
|
+
/** Inline SQL result panel — shows query results below the editor */
|
|
662
|
+
function SqlResultPanel({ result, error, loading, connName, onClose, onOpenInTab }: {
|
|
663
|
+
result: DbQueryResult | null;
|
|
664
|
+
error: string | null;
|
|
665
|
+
loading: boolean;
|
|
666
|
+
connName?: string;
|
|
667
|
+
onClose: () => void;
|
|
668
|
+
onOpenInTab: () => void;
|
|
669
|
+
}) {
|
|
670
|
+
const tableData = useMemo(() => (
|
|
671
|
+
result?.changeType === "select" && result.rows.length > 0
|
|
672
|
+
? { columns: result.columns, rows: result.rows, total: result.rows.length, limit: result.rows.length }
|
|
673
|
+
: null
|
|
674
|
+
), [result]);
|
|
675
|
+
|
|
676
|
+
const querySchema = useMemo<DbColumnInfo[]>(() => (
|
|
677
|
+
(result?.columns ?? []).map((c) => ({ name: c, type: "text", nullable: true, pk: false, defaultValue: null }))
|
|
678
|
+
), [result?.columns]);
|
|
679
|
+
|
|
680
|
+
const [panelHeight, setPanelHeight] = useState(250);
|
|
681
|
+
const handleDrag = useCallback((e: React.MouseEvent) => {
|
|
682
|
+
e.preventDefault();
|
|
683
|
+
const startY = e.clientY;
|
|
684
|
+
const startH = panelHeight;
|
|
685
|
+
const onMove = (ev: MouseEvent) => setPanelHeight(Math.max(80, startH + (startY - ev.clientY)));
|
|
686
|
+
const onUp = () => { document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); };
|
|
687
|
+
document.addEventListener("mousemove", onMove);
|
|
688
|
+
document.addEventListener("mouseup", onUp);
|
|
689
|
+
}, [panelHeight]);
|
|
690
|
+
|
|
691
|
+
return (
|
|
692
|
+
<div className="shrink-0 border-t border-border flex flex-col" style={{ height: panelHeight }}>
|
|
693
|
+
{/* Resize handle */}
|
|
694
|
+
<div onMouseDown={handleDrag}
|
|
695
|
+
className="shrink-0 h-1.5 cursor-row-resize bg-border/50 hover:bg-primary/30 flex items-center justify-center transition-colors">
|
|
696
|
+
<GripHorizontal className="size-3 text-muted-foreground/50" />
|
|
697
|
+
</div>
|
|
698
|
+
{/* Title bar */}
|
|
699
|
+
<div className="flex items-center gap-2 px-2 py-1 bg-muted/50 border-b border-border shrink-0">
|
|
700
|
+
<Database className="size-3 text-muted-foreground" />
|
|
701
|
+
<span className="text-xs font-medium text-foreground truncate flex-1">
|
|
702
|
+
{connName ? `${connName} · Results` : "Query Results"}
|
|
703
|
+
{result?.executionTimeMs != null && <span className="text-muted-foreground ml-1.5 font-normal">{result.executionTimeMs}ms</span>}
|
|
704
|
+
</span>
|
|
705
|
+
<button type="button" onClick={onOpenInTab} title="Open in DB Viewer tab"
|
|
706
|
+
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">
|
|
707
|
+
<ExternalLink className="size-3" />
|
|
708
|
+
<span className="hidden sm:inline">Open in Tab</span>
|
|
709
|
+
</button>
|
|
710
|
+
<button type="button" onClick={onClose} title="Close results"
|
|
711
|
+
className="p-0.5 rounded text-muted-foreground hover:text-foreground transition-colors">
|
|
712
|
+
<X className="size-3" />
|
|
713
|
+
</button>
|
|
714
|
+
</div>
|
|
715
|
+
|
|
716
|
+
{/* Content */}
|
|
717
|
+
<div className="flex-1 overflow-hidden min-h-0">
|
|
718
|
+
{loading && (
|
|
719
|
+
<div className="flex items-center justify-center h-full">
|
|
720
|
+
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
|
721
|
+
</div>
|
|
722
|
+
)}
|
|
723
|
+
{error && <div className="px-3 py-2 text-xs text-destructive bg-destructive/5">{error}</div>}
|
|
724
|
+
{result?.changeType === "modify" && (
|
|
725
|
+
<div className="px-3 py-2 text-xs text-green-500">
|
|
726
|
+
{result.rowsAffected} row(s) affected
|
|
727
|
+
</div>
|
|
728
|
+
)}
|
|
729
|
+
{tableData && (
|
|
730
|
+
<DataGrid
|
|
731
|
+
tableData={tableData} schema={querySchema} loading={false}
|
|
732
|
+
page={1} onPageChange={NOOP} onCellUpdate={NOOP}
|
|
733
|
+
orderBy={null} orderDir="ASC" onToggleSort={NOOP}
|
|
734
|
+
connectionName={connName}
|
|
735
|
+
/>
|
|
736
|
+
)}
|
|
737
|
+
{result?.changeType === "select" && result.rows.length === 0 && (
|
|
738
|
+
<div className="px-3 py-2 text-xs text-muted-foreground">No results</div>
|
|
739
|
+
)}
|
|
740
|
+
</div>
|
|
741
|
+
</div>
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
|
|
628
745
|
function LoadingSpinner() {
|
|
629
746
|
return <div className="flex items-center justify-center h-full"><Loader2 className="size-5 animate-spin text-text-subtle" /></div>;
|
|
630
747
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useRef, useEffect, useState, useCallback, memo } from "react";
|
|
2
2
|
import { useTerminal } from "@/hooks/use-terminal";
|
|
3
3
|
import { cn } from "@/lib/utils";
|
|
4
|
-
import { Copy, ClipboardPaste } from "lucide-react";
|
|
4
|
+
import { Copy, ClipboardPaste, RotateCcw } from "lucide-react";
|
|
5
5
|
import "@xterm/xterm/css/xterm.css";
|
|
6
6
|
|
|
7
7
|
interface TerminalTabProps {
|
|
@@ -22,7 +22,7 @@ export const TerminalTab = memo(function TerminalTab({ metadata }: TerminalTabPr
|
|
|
22
22
|
const sessionId = (metadata?.sessionId as string) ?? "new";
|
|
23
23
|
const projectName = metadata?.projectName as string | undefined;
|
|
24
24
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
25
|
-
const { connected, reconnecting, sendData, getSelection } = useTerminal({ sessionId, projectName, containerRef });
|
|
25
|
+
const { connected, reconnecting, exited, sendData, getSelection, restart } = useTerminal({ sessionId, projectName, containerRef });
|
|
26
26
|
const [ctrlMode, setCtrlMode] = useState(false);
|
|
27
27
|
const [viewportHeight, setViewportHeight] = useState<number | null>(null);
|
|
28
28
|
|
|
@@ -97,16 +97,27 @@ export const TerminalTab = memo(function TerminalTab({ metadata }: TerminalTabPr
|
|
|
97
97
|
<span
|
|
98
98
|
className={cn(
|
|
99
99
|
"size-2 rounded-full",
|
|
100
|
-
connected ? "bg-success" : reconnecting ? "bg-warning" : "bg-error",
|
|
100
|
+
exited ? "bg-error" : connected ? "bg-success" : reconnecting ? "bg-warning" : "bg-error",
|
|
101
101
|
)}
|
|
102
102
|
/>
|
|
103
103
|
<span className="text-text-secondary">
|
|
104
|
-
{
|
|
105
|
-
? "
|
|
106
|
-
:
|
|
107
|
-
? "
|
|
108
|
-
:
|
|
104
|
+
{exited
|
|
105
|
+
? "Process exited"
|
|
106
|
+
: connected
|
|
107
|
+
? "Connected"
|
|
108
|
+
: reconnecting
|
|
109
|
+
? "Reconnecting..."
|
|
110
|
+
: "Disconnected"}
|
|
109
111
|
</span>
|
|
112
|
+
{exited && (
|
|
113
|
+
<button
|
|
114
|
+
onClick={restart}
|
|
115
|
+
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-surface-elevated text-text-primary hover:bg-primary hover:text-primary-foreground active:bg-primary active:text-primary-foreground transition-colors"
|
|
116
|
+
>
|
|
117
|
+
<RotateCcw size={10} />
|
|
118
|
+
Restart
|
|
119
|
+
</button>
|
|
120
|
+
)}
|
|
110
121
|
<span className="text-text-subtle ml-auto font-mono">{sessionId}</span>
|
|
111
122
|
</div>
|
|
112
123
|
|
|
@@ -66,8 +66,10 @@ interface UseTerminalOptions {
|
|
|
66
66
|
interface UseTerminalReturn {
|
|
67
67
|
connected: boolean;
|
|
68
68
|
reconnecting: boolean;
|
|
69
|
+
exited: boolean;
|
|
69
70
|
sendData: (data: string) => void;
|
|
70
71
|
getSelection: () => string;
|
|
72
|
+
restart: () => void;
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
const RESIZE_PREFIX = "\x01RESIZE:";
|
|
@@ -83,6 +85,7 @@ export function useTerminal(
|
|
|
83
85
|
const reconnectAttempts = useRef(0);
|
|
84
86
|
const [connected, setConnected] = useState(false);
|
|
85
87
|
const [reconnecting, setReconnecting] = useState(false);
|
|
88
|
+
const [exited, setExited] = useState(false);
|
|
86
89
|
const actualSessionId = useRef(sessionId); // Track server-assigned session ID
|
|
87
90
|
|
|
88
91
|
const sendData = useCallback((data: string) => {
|
|
@@ -104,6 +107,20 @@ export function useTerminal(
|
|
|
104
107
|
}
|
|
105
108
|
}, []);
|
|
106
109
|
|
|
110
|
+
const restart = useCallback(() => {
|
|
111
|
+
// Close existing WS, reset to "new" session, reconnect
|
|
112
|
+
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
|
113
|
+
wsRef.current?.close();
|
|
114
|
+
wsRef.current = null;
|
|
115
|
+
actualSessionId.current = "new";
|
|
116
|
+
reconnectAttempts.current = 0;
|
|
117
|
+
setExited(false);
|
|
118
|
+
setConnected(false);
|
|
119
|
+
setReconnecting(false);
|
|
120
|
+
// connectWs will be called after this via setTimeout to allow state to settle
|
|
121
|
+
setTimeout(() => connectWs(), 0);
|
|
122
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
123
|
+
|
|
107
124
|
const connectWs = useCallback(() => {
|
|
108
125
|
const term = termRef.current;
|
|
109
126
|
if (!term) return;
|
|
@@ -130,13 +147,16 @@ export function useTerminal(
|
|
|
130
147
|
if (event.data.startsWith("{")) {
|
|
131
148
|
try {
|
|
132
149
|
const msg = JSON.parse(event.data);
|
|
133
|
-
if (msg.type === "session" || msg.type === "error") {
|
|
150
|
+
if (msg.type === "session" || msg.type === "error" || msg.type === "exited") {
|
|
134
151
|
if (msg.type === "session" && msg.id) {
|
|
135
152
|
actualSessionId.current = msg.id; // Save for reconnect
|
|
136
153
|
}
|
|
137
154
|
if (msg.type === "error") {
|
|
138
155
|
term.write(`\r\n\x1b[31mError: ${msg.message}\x1b[0m\r\n`);
|
|
139
156
|
}
|
|
157
|
+
if (msg.type === "exited") {
|
|
158
|
+
setExited(true);
|
|
159
|
+
}
|
|
140
160
|
return; // Don't write raw JSON to terminal
|
|
141
161
|
}
|
|
142
162
|
} catch {
|
|
@@ -234,5 +254,5 @@ export function useTerminal(
|
|
|
234
254
|
};
|
|
235
255
|
}, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
236
256
|
|
|
237
|
-
return { connected, reconnecting, sendData, getSelection };
|
|
257
|
+
return { connected, reconnecting, exited, sendData, getSelection, restart };
|
|
238
258
|
}
|
package/src/web/index.html
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
<link href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@400;500;600;700&family=Geist:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
|
12
12
|
</head>
|
|
13
13
|
<body class="bg-[#0f1419] text-[#e5e7eb] font-sans antialiased">
|
|
14
|
+
<div id="portal" style="position: fixed; left: 0; top: 0; z-index: 9999;" /></div>
|
|
14
15
|
<div id="root"></div>
|
|
15
16
|
<script type="module" src="./main.tsx"></script>
|
|
16
17
|
</body>
|
|
@@ -89,6 +89,7 @@ export function getNextUntitledNumber(panels: Record<string, Panel>): number {
|
|
|
89
89
|
export function deriveTabId(type: TabType, metadata?: Record<string, unknown>): string {
|
|
90
90
|
switch (type) {
|
|
91
91
|
case "editor":
|
|
92
|
+
if (metadata?.viewerKey) return `editor:viewer:${metadata.viewerKey}`;
|
|
92
93
|
if (metadata?.isUntitled) return `editor:untitled-${metadata.untitledNumber ?? 1}`;
|
|
93
94
|
return `editor:${metadata?.filePath ?? "untitled"}`;
|
|
94
95
|
case "chat": {
|
package/test.sql
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
select * from config;
|