@hienlh/ppm 0.13.20 → 0.13.22
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 +12 -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-DR5BueEL.js → ai-settings-section-DN4egS8e.js} +1 -1
- package/dist/web/assets/architecture-PBZL5I3N-CZBayZMd.js +1 -0
- package/dist/web/assets/{audio-preview-DwyrUe-V.js → audio-preview-CrTLA4VQ.js} +1 -1
- package/dist/web/assets/chat-tab-DFCOXFk8.js +12 -0
- package/dist/web/assets/code-editor-J864BoOW.js +8 -0
- package/dist/web/assets/{conflict-editor-C8vTvS9w.js → conflict-editor-BIwUtzO5.js} +1 -1
- package/dist/web/assets/{csv-preview-Bo-N3GHl.js → csv-preview-BIfojSWd.js} +1 -1
- package/dist/web/assets/{data-grid-overlay-editor-DqcDQ9st.js → data-grid-overlay-editor-DZIqEOsz.js} +1 -1
- package/dist/web/assets/{database-viewer-_RTlPC26.js → database-viewer-DhawNQtp.js} +1 -1
- package/dist/web/assets/{diff-viewer-P2Dc__bQ.js → diff-viewer-nupJr1AG.js} +1 -1
- package/dist/web/assets/{esm-Dvc8oJly.js → esm-UZtw2QcY.js} +1 -1
- package/dist/web/assets/{extension-webview-CHqtkQBd.js → extension-webview-BXDYtTXe.js} +2 -2
- package/dist/web/assets/gitGraph-HDMCJU4V-CboO1wK8.js +1 -0
- package/dist/web/assets/{glide-data-grid-9TPVejSQ.js → glide-data-grid-DttB_tob.js} +6 -6
- package/dist/web/assets/{image-preview--nh-wHgF.js → image-preview-Dh11TP_j.js} +1 -1
- package/dist/web/assets/index-C5sLGvFC.css +2 -0
- package/dist/web/assets/index-CPcnZtNl.js +27 -0
- package/dist/web/assets/info-3K5VOQVL-D_qKNgUf.js +1 -0
- package/dist/web/assets/keybindings-store-DvBC5IaA.js +1 -0
- package/dist/web/assets/{markdown-renderer-Bsow9WVr.js → markdown-renderer-Bwpgzn7n.js} +3 -3
- package/dist/web/assets/notification-store-D1sxDh0s.js +1 -0
- package/dist/web/assets/{number-overlay-editor-XTjjEXtk.js → number-overlay-editor-CewUR5pB.js} +1 -1
- package/dist/web/assets/packet-RMMSAZCW-XtGc2GdX.js +1 -0
- package/dist/web/assets/{pdf-preview-BqntOcNA.js → pdf-preview-CI-lrcdD.js} +1 -1
- package/dist/web/assets/pie-UPGHQEXC-DNZ5YtCW.js +1 -0
- package/dist/web/assets/{port-forwarding-tab-WWRLWcTB.js → port-forwarding-tab-DOEfu8ca.js} +1 -1
- package/dist/web/assets/{postgres-viewer-g7-3kOzD.js → postgres-viewer-Bb3RwFMj.js} +3 -3
- package/dist/web/assets/radar-KQ55EAFF-uCGpAvZE.js +1 -0
- package/dist/web/assets/{settings-store-D2MtC9tm.js → settings-store-CVrIYYCB.js} +2 -2
- package/dist/web/assets/settings-tab-i8KAi1LY.js +1 -0
- package/dist/web/assets/{sql-query-editor-CultKZsI.js → sql-query-editor-C3ZrhqZr.js} +1 -1
- package/dist/web/assets/sqlite-viewer-Cucs41S6.js +1 -0
- package/dist/web/assets/terminal-tab-upGE8feC.js +1 -0
- package/dist/web/assets/treemap-KZPCXAKY-DQvivjBa.js +1 -0
- package/dist/web/assets/{use-monaco-theme-CugUkORI.js → use-monaco-theme-BePWbY58.js} +1 -1
- package/dist/web/assets/{vendor-mermaid-CPtQ2zua.js → vendor-mermaid-Cl50p6TB.js} +2 -2
- package/dist/web/assets/{video-preview-C4PxtiOc.js → video-preview-CSdxf4fH.js} +1 -1
- package/dist/web/index.html +6 -6
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/providers/claude-agent-sdk.ts +13 -1
- package/src/server/routes/chat.ts +73 -3
- package/src/server/routes/database.ts +11 -2
- package/src/server/ws/chat.ts +12 -3
- package/src/services/autostart-generator.ts +2 -2
- package/src/services/autostart-register.ts +6 -3
- package/src/services/db.service.ts +41 -1
- package/src/services/supervisor.ts +20 -7
- package/src/web/app.tsx +12 -0
- package/src/web/components/chat/chat-history-bar.tsx +43 -13
- package/src/web/components/chat/chat-tab.tsx +3 -0
- package/src/web/components/chat/session-list-panel.tsx +15 -8
- package/src/web/components/chat/session-picker.tsx +33 -5
- package/src/web/components/database/connection-list.tsx +55 -205
- package/src/web/components/database/connection-row.tsx +104 -0
- package/src/web/components/database/database-sidebar.tsx +1 -1
- package/src/web/components/database/schema-table-tree.tsx +98 -0
- package/src/web/components/database/use-connections.ts +9 -6
- package/src/web/components/layout/command-palette.tsx +10 -2
- package/src/web/components/layout/editor-panel.tsx +16 -39
- package/src/web/components/layout/tab-pool.tsx +196 -0
- package/src/web/hooks/use-chat.ts +9 -2
- package/src/web/hooks/use-debounced-value.ts +10 -0
- package/src/web/stores/notification-store.ts +42 -0
- package/dist/web/assets/architecture-PBZL5I3N-7JKY4P1L.js +0 -1
- package/dist/web/assets/chat-tab-DqS9Qk3O.js +0 -12
- package/dist/web/assets/code-editor-DwdeigGe.js +0 -8
- package/dist/web/assets/gitGraph-HDMCJU4V-Daf9rhiF.js +0 -1
- package/dist/web/assets/index-nC9UURj4.css +0 -2
- package/dist/web/assets/index-xpTdWKsA.js +0 -27
- package/dist/web/assets/info-3K5VOQVL-gn0pjNiT.js +0 -1
- package/dist/web/assets/keybindings-store-C0XkvJcm.js +0 -1
- package/dist/web/assets/packet-RMMSAZCW-Csaeizjc.js +0 -1
- package/dist/web/assets/pie-UPGHQEXC-DatkjxTH.js +0 -1
- package/dist/web/assets/radar-KQ55EAFF-BnGB20hR.js +0 -1
- package/dist/web/assets/settings-tab-DO3s244B.js +0 -1
- package/dist/web/assets/sqlite-viewer-BtYh66b0.js +0 -1
- package/dist/web/assets/terminal-tab-C25rc_34.js +0 -1
- package/dist/web/assets/treemap-KZPCXAKY-CgEYv38e.js +0 -1
- /package/dist/web/assets/{api-settings-DowGyuVy.js → api-settings-DnHv6JgF.js} +0 -0
- /package/dist/web/assets/{data-grid-types-DqqspyVw.js → data-grid-types-BISkUXAY.js} +0 -0
- /package/dist/web/assets/{dist-_jZs3YZC.js → dist-B1I_4Jtc.js} +0 -0
- /package/dist/web/assets/{dist-D1SZxtVS.js → dist-CcDNqGjt.js} +0 -0
- /package/dist/web/assets/{katex-DzXRfQ_m.js → katex-Bqvo_ZG0.js} +0 -0
- /package/dist/web/assets/{lib-Dub8DlCJ.js → lib-Bu71-TFS.js} +0 -0
- /package/dist/web/assets/{use-blob-url-DGY5qKiT.js → use-blob-url-QX-XajU8.js} +0 -0
- /package/dist/web/assets/{vendor-xterm-Dyfw49hJ.js → vendor-xterm-K3_Xwigj.js} +0 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { ChevronRight, ChevronDown, RefreshCw, Pencil, Trash2, Lock, Search } from "lucide-react";
|
|
2
|
+
import { cn } from "@/lib/utils";
|
|
3
|
+
import type { Connection, CachedTable } from "./use-connections";
|
|
4
|
+
import { SchemaTableTree, type ColumnInfo } from "./schema-table-tree";
|
|
5
|
+
|
|
6
|
+
interface ConnectionRowProps {
|
|
7
|
+
conn: Connection;
|
|
8
|
+
isExpanded: boolean;
|
|
9
|
+
isRefreshing: boolean;
|
|
10
|
+
tables: CachedTable[];
|
|
11
|
+
schemas: Map<string, CachedTable[]>;
|
|
12
|
+
isSingleSchema: boolean;
|
|
13
|
+
filter: string;
|
|
14
|
+
expandedTables: Set<string>;
|
|
15
|
+
loadingColumns: Set<string>;
|
|
16
|
+
columnCache?: Map<string, ColumnInfo[]>;
|
|
17
|
+
columnErrors?: Set<string>;
|
|
18
|
+
refreshError?: string;
|
|
19
|
+
hasGroup: boolean;
|
|
20
|
+
onToggle: (id: number, autoRefresh: boolean) => void;
|
|
21
|
+
onRefresh: (id: number) => void;
|
|
22
|
+
onEdit: (conn: Connection) => void;
|
|
23
|
+
onDelete: (id: number) => void;
|
|
24
|
+
onOpenTable: (conn: Connection, tableName: string, schemaName: string) => void;
|
|
25
|
+
onToggleTable: (connId: number, tableName: string, schemaName: string) => void;
|
|
26
|
+
onFilterChange: (connId: number, value: string) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function ConnectionRow({
|
|
30
|
+
conn, isExpanded, isRefreshing, tables, schemas, isSingleSchema,
|
|
31
|
+
filter, expandedTables, loadingColumns, columnCache, columnErrors,
|
|
32
|
+
refreshError, hasGroup,
|
|
33
|
+
onToggle, onRefresh, onEdit, onDelete, onOpenTable, onToggleTable, onFilterChange,
|
|
34
|
+
}: ConnectionRowProps) {
|
|
35
|
+
const handleToggle = () => onToggle(conn.id, !isExpanded && tables.length === 0);
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div>
|
|
39
|
+
<div className={cn("group flex items-center gap-1 py-1 hover:bg-surface-elevated transition-colors", hasGroup ? "pl-3 pr-2" : "px-2")}>
|
|
40
|
+
<button onClick={handleToggle} className="shrink-0 text-text-subtle hover:text-foreground transition-colors">
|
|
41
|
+
{isExpanded ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
|
|
42
|
+
</button>
|
|
43
|
+
<span className="shrink-0 size-2 rounded-full border border-border" style={{ backgroundColor: conn.color ?? "transparent" }} />
|
|
44
|
+
<button className="flex-1 text-left text-xs truncate hover:text-primary transition-colors" onClick={handleToggle}>
|
|
45
|
+
{conn.name}
|
|
46
|
+
</button>
|
|
47
|
+
<span className="shrink-0 text-[9px] text-text-subtle uppercase px-1 rounded bg-surface-elevated">
|
|
48
|
+
{conn.type === "postgres" ? "PG" : "DB"}
|
|
49
|
+
</span>
|
|
50
|
+
{conn.readonly === 1 && <span title="Readonly"><Lock className="shrink-0 size-2.5 text-text-subtle" /></span>}
|
|
51
|
+
<div className="flex can-hover:hidden can-hover:group-hover:flex items-center gap-0.5 shrink-0">
|
|
52
|
+
<button onClick={() => onRefresh(conn.id)} disabled={isRefreshing} className="p-0.5 text-text-subtle hover:text-foreground transition-colors" title="Refresh tables">
|
|
53
|
+
<RefreshCw className={cn("size-3", isRefreshing && "animate-spin")} />
|
|
54
|
+
</button>
|
|
55
|
+
<button onClick={() => onEdit(conn)} className="p-0.5 text-text-subtle hover:text-foreground transition-colors" title="Edit">
|
|
56
|
+
<Pencil className="size-3" />
|
|
57
|
+
</button>
|
|
58
|
+
<button onClick={() => onDelete(conn.id)} className="p-0.5 text-text-subtle hover:text-red-500 transition-colors" title="Delete">
|
|
59
|
+
<Trash2 className="size-3" />
|
|
60
|
+
</button>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
{isExpanded && (
|
|
65
|
+
<div className="ml-[11px] border-l border-dashed border-border pl-1">
|
|
66
|
+
{isRefreshing && tables.length === 0 && <p className="text-[10px] text-text-subtle px-2 py-1">Loading…</p>}
|
|
67
|
+
{!isRefreshing && tables.length === 0 && (
|
|
68
|
+
refreshError
|
|
69
|
+
? <p className="text-[10px] text-red-500 px-2 py-1 break-all">{refreshError}</p>
|
|
70
|
+
: <p className="text-[10px] text-text-subtle px-2 py-1">No tables cached</p>
|
|
71
|
+
)}
|
|
72
|
+
{tables.length > 0 && (
|
|
73
|
+
<>
|
|
74
|
+
{tables.length > 5 && (
|
|
75
|
+
<div className="flex items-center gap-1 px-2 py-0.5">
|
|
76
|
+
<Search className="size-2.5 text-text-subtle shrink-0" />
|
|
77
|
+
<input
|
|
78
|
+
type="text"
|
|
79
|
+
value={filter}
|
|
80
|
+
onChange={(e) => onFilterChange(conn.id, e.target.value)}
|
|
81
|
+
placeholder="Filter tables…"
|
|
82
|
+
className="w-full text-[10px] bg-transparent border-none outline-none text-foreground placeholder:text-text-subtle"
|
|
83
|
+
/>
|
|
84
|
+
</div>
|
|
85
|
+
)}
|
|
86
|
+
<SchemaTableTree
|
|
87
|
+
connId={conn.id}
|
|
88
|
+
schemas={schemas}
|
|
89
|
+
isSingleSchema={isSingleSchema}
|
|
90
|
+
filter={filter}
|
|
91
|
+
expandedTables={expandedTables}
|
|
92
|
+
loadingColumns={loadingColumns}
|
|
93
|
+
columnCache={columnCache}
|
|
94
|
+
columnErrors={columnErrors}
|
|
95
|
+
onToggleTable={onToggleTable}
|
|
96
|
+
onOpenTable={(tableName, schemaName) => onOpenTable(conn, tableName, schemaName)}
|
|
97
|
+
/>
|
|
98
|
+
</>
|
|
99
|
+
)}
|
|
100
|
+
</div>
|
|
101
|
+
)}
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -24,7 +24,7 @@ export function DatabaseSidebar() {
|
|
|
24
24
|
|
|
25
25
|
const handleDelete = async (id: number) => {
|
|
26
26
|
if (!confirm("Delete this connection?")) return;
|
|
27
|
-
await deleteConnection(id);
|
|
27
|
+
try { await deleteConnection(id); } catch { /* server error — connection list will stay in sync on next fetch */ }
|
|
28
28
|
};
|
|
29
29
|
|
|
30
30
|
const handleCreate = async (data: CreateConnectionData) => {
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { ChevronRight, ChevronDown, Database, Key, Link2 } from "lucide-react";
|
|
2
|
+
import type { CachedTable } from "./use-connections";
|
|
3
|
+
|
|
4
|
+
export interface ColumnInfo {
|
|
5
|
+
name: string;
|
|
6
|
+
type: string;
|
|
7
|
+
nullable: boolean;
|
|
8
|
+
pk: boolean;
|
|
9
|
+
fk: { table: string; column: string } | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface SchemaTableTreeProps {
|
|
13
|
+
connId: number;
|
|
14
|
+
schemas: Map<string, CachedTable[]>;
|
|
15
|
+
isSingleSchema: boolean;
|
|
16
|
+
filter: string;
|
|
17
|
+
expandedTables: Set<string>;
|
|
18
|
+
loadingColumns: Set<string>;
|
|
19
|
+
columnCache?: Map<string, ColumnInfo[]>;
|
|
20
|
+
columnErrors?: Set<string>;
|
|
21
|
+
onToggleTable: (connId: number, tableName: string, schemaName: string) => void;
|
|
22
|
+
onOpenTable: (tableName: string, schemaName: string) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function SchemaTableTree({
|
|
26
|
+
connId, schemas, isSingleSchema, filter,
|
|
27
|
+
expandedTables, loadingColumns, columnCache, columnErrors,
|
|
28
|
+
onToggleTable, onOpenTable,
|
|
29
|
+
}: SchemaTableTreeProps) {
|
|
30
|
+
const filterLower = filter.toLowerCase();
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className="overflow-y-auto max-h-[40vh]">
|
|
34
|
+
{Array.from(schemas.entries()).map(([schemaName, tables]) => {
|
|
35
|
+
const filteredTables = filterLower
|
|
36
|
+
? tables.filter((t) => t.tableName.toLowerCase().includes(filterLower))
|
|
37
|
+
: tables;
|
|
38
|
+
if (filteredTables.length === 0) return null;
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div key={schemaName}>
|
|
42
|
+
{!isSingleSchema && (
|
|
43
|
+
<p className="px-2 py-0.5 text-[9px] font-semibold text-text-subtle uppercase tracking-wider">{schemaName}</p>
|
|
44
|
+
)}
|
|
45
|
+
{filteredTables.map((t) => {
|
|
46
|
+
const tableKey = `${connId}:${t.schemaName}.${t.tableName}`;
|
|
47
|
+
const isTableExpanded = expandedTables.has(tableKey);
|
|
48
|
+
const isLoadingCols = loadingColumns.has(tableKey);
|
|
49
|
+
const columns = columnCache?.get(tableKey);
|
|
50
|
+
const hasError = columnErrors?.has(tableKey);
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div key={tableKey}>
|
|
54
|
+
<div className="flex items-center gap-1 pl-2 pr-2 py-0.5 hover:bg-surface-elevated transition-colors group/table">
|
|
55
|
+
<button
|
|
56
|
+
onClick={() => onToggleTable(connId, t.tableName, t.schemaName)}
|
|
57
|
+
className="shrink-0 text-text-subtle hover:text-foreground transition-colors"
|
|
58
|
+
>
|
|
59
|
+
{isTableExpanded ? <ChevronDown className="size-2.5" /> : <ChevronRight className="size-2.5" />}
|
|
60
|
+
</button>
|
|
61
|
+
<Database className="size-2.5 shrink-0 text-text-subtle" />
|
|
62
|
+
<button
|
|
63
|
+
onClick={() => onOpenTable(t.tableName, t.schemaName)}
|
|
64
|
+
className="flex-1 text-left text-[11px] text-text-secondary hover:text-foreground transition-colors truncate"
|
|
65
|
+
>
|
|
66
|
+
{t.tableName}
|
|
67
|
+
</button>
|
|
68
|
+
<span className="text-[9px] text-text-subtle">{t.rowCount}</span>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
{isTableExpanded && (
|
|
72
|
+
<div className="ml-[18px] border-l border-dotted border-border pl-2">
|
|
73
|
+
{isLoadingCols && <p className="text-[9px] text-text-subtle px-1 py-0.5">Loading…</p>}
|
|
74
|
+
{hasError && <p className="text-[9px] text-red-500 px-1 py-0.5">Failed to load columns</p>}
|
|
75
|
+
{columns && columns.map((col) => (
|
|
76
|
+
<div key={col.name} className="flex items-center gap-1 px-1 py-px text-[10px] text-text-subtle" title={col.fk ? `FK → ${col.fk.table}.${col.fk.column}` : undefined}>
|
|
77
|
+
{col.pk && <Key className="size-2.5 text-amber-500 shrink-0" />}
|
|
78
|
+
{col.fk && <Link2 className="size-2.5 text-blue-400 shrink-0" />}
|
|
79
|
+
{!col.pk && !col.fk && <span className="size-2.5 shrink-0" />}
|
|
80
|
+
<span className="truncate">{col.name}{col.nullable ? "?" : ""}</span>
|
|
81
|
+
<span className="ml-auto text-[9px] text-text-subtle/60 shrink-0">{col.type}</span>
|
|
82
|
+
</div>
|
|
83
|
+
))}
|
|
84
|
+
{!isLoadingCols && !hasError && !columns && <p className="text-[9px] text-text-subtle px-1 py-0.5">No columns</p>}
|
|
85
|
+
</div>
|
|
86
|
+
)}
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
})}
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
})}
|
|
93
|
+
{filter && Array.from(schemas.values()).every((t) => !t.some((x) => x.tableName.toLowerCase().includes(filterLower))) && (
|
|
94
|
+
<p className="text-[10px] text-text-subtle px-2 py-1">No match</p>
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useEffect, useCallback } from "react";
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from "react";
|
|
2
2
|
import { api } from "../../lib/api-client";
|
|
3
3
|
|
|
4
4
|
export interface Connection {
|
|
@@ -104,17 +104,20 @@ export function useConnections() {
|
|
|
104
104
|
}, []);
|
|
105
105
|
|
|
106
106
|
/** Fetch column metadata for a table (lazy loaded for schema tree) */
|
|
107
|
-
|
|
108
|
-
const
|
|
107
|
+
type ColumnInfo = { name: string; type: string; nullable: boolean; pk: boolean; fk: { table: string; column: string } | null };
|
|
108
|
+
const [columnCache, setColumnCache] = useState<Map<string, ColumnInfo[]>>(new Map());
|
|
109
|
+
const columnCacheRef = useRef(columnCache);
|
|
110
|
+
columnCacheRef.current = columnCache;
|
|
111
|
+
const fetchColumns = useCallback(async (connId: number, table: string, schema?: string): Promise<ColumnInfo[]> => {
|
|
109
112
|
const cacheKey = `${connId}:${schema ?? "main"}.${table}`;
|
|
110
|
-
const cached =
|
|
113
|
+
const cached = columnCacheRef.current.get(cacheKey);
|
|
111
114
|
if (cached) return cached;
|
|
112
|
-
const cols = await api.get<
|
|
115
|
+
const cols = await api.get<ColumnInfo[]>(
|
|
113
116
|
`/api/db/connections/${connId}/schema?table=${encodeURIComponent(table)}${schema ? `&schema=${encodeURIComponent(schema)}` : ""}`,
|
|
114
117
|
);
|
|
115
118
|
setColumnCache((prev) => new Map(prev).set(cacheKey, cols));
|
|
116
119
|
return cols;
|
|
117
|
-
}, [
|
|
120
|
+
}, []);
|
|
118
121
|
|
|
119
122
|
const exportConnections = useCallback(async () => {
|
|
120
123
|
return api.get<{ version: number; exported_at: string; connections: unknown[] }>("/api/db/connections/export");
|
|
@@ -371,7 +371,8 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
|
|
|
371
371
|
|
|
372
372
|
// Normal mode
|
|
373
373
|
if (!deferredQuery.trim()) return actionCommands;
|
|
374
|
-
|
|
374
|
+
// Strip leading ./ or ../ — index paths are relative without dot prefix
|
|
375
|
+
const qLower = deferredQuery.toLowerCase().replace(/^\.\.?\//, "");
|
|
375
376
|
const scored: Array<{ cmd: CommandItem; score: FileSearchScore }> = [];
|
|
376
377
|
for (const entry of searchIndex) {
|
|
377
378
|
const s = scoreFileSearchFast(qLower, entry.filenameLower, entry.pathLower, entry.labelLen, entry.depth);
|
|
@@ -383,6 +384,13 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
|
|
|
383
384
|
return deferredQuery.trim().length >= 2 ? [...dbCommands, ...matched] : matched;
|
|
384
385
|
}, [searchIndex, actionCommands, fsCommands, dbCommands, deferredQuery]);
|
|
385
386
|
|
|
387
|
+
// Auto-load file index when palette opens and index isn't ready
|
|
388
|
+
useEffect(() => {
|
|
389
|
+
if (open && indexStatus === "idle" && activeProject) {
|
|
390
|
+
loadIndex(activeProject.name);
|
|
391
|
+
}
|
|
392
|
+
}, [open, indexStatus, activeProject, loadIndex]);
|
|
393
|
+
|
|
386
394
|
// Reset state when opening
|
|
387
395
|
useEffect(() => {
|
|
388
396
|
if (open) {
|
|
@@ -484,7 +492,7 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
|
|
|
484
492
|
)}
|
|
485
493
|
|
|
486
494
|
{/* Index status hints — non-blocking, muted */}
|
|
487
|
-
{!pathMode && indexStatus === "loading" && (
|
|
495
|
+
{!pathMode && (indexStatus === "loading" || indexStatus === "idle") && (
|
|
488
496
|
<div className="flex items-center gap-1.5 px-3 py-1.5 border-b border-border/50">
|
|
489
497
|
<Loader2 className="size-3 animate-spin text-text-subtle shrink-0" />
|
|
490
498
|
<span className="text-[11px] text-text-subtle italic">Indexing project…</span>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { useCallback, useEffect } from "react";
|
|
2
|
+
import { Terminal, MessageSquare, FilePlus } from "lucide-react";
|
|
3
3
|
import { usePanelStore } from "@/stores/panel-store";
|
|
4
4
|
import { useProjectStore } from "@/stores/project-store";
|
|
5
5
|
import { useTabStore, type TabType } from "@/stores/tab-store";
|
|
@@ -7,6 +7,7 @@ import { SessionListPanel } from "@/components/chat/session-list-panel";
|
|
|
7
7
|
import type { SessionInfo } from "../../../types/chat";
|
|
8
8
|
import { TabBar } from "./tab-bar";
|
|
9
9
|
import { SplitDropOverlay } from "./split-drop-overlay";
|
|
10
|
+
import { registerPanelSlot } from "./tab-pool";
|
|
10
11
|
import { cn } from "@/lib/utils";
|
|
11
12
|
|
|
12
13
|
const QUICK_OPEN_TABS: { type: TabType; label: string; icon: React.ElementType }[] = [
|
|
@@ -15,21 +16,6 @@ const QUICK_OPEN_TABS: { type: TabType; label: string; icon: React.ElementType }
|
|
|
15
16
|
{ type: "editor", label: "New File", icon: FilePlus },
|
|
16
17
|
];
|
|
17
18
|
|
|
18
|
-
const TAB_COMPONENTS: Record<TabType, React.LazyExoticComponent<React.ComponentType<{ metadata?: Record<string, unknown>; tabId?: string }>>> = {
|
|
19
|
-
terminal: lazy(() => import("@/components/terminal/terminal-tab").then((m) => ({ default: m.TerminalTab }))),
|
|
20
|
-
chat: lazy(() => import("@/components/chat/chat-tab").then((m) => ({ default: m.ChatTab }))),
|
|
21
|
-
editor: lazy(() => import("@/components/editor/code-editor").then((m) => ({ default: m.CodeEditor }))),
|
|
22
|
-
database: lazy(() => import("@/components/database/database-viewer").then((m) => ({ default: m.DatabaseViewer }))),
|
|
23
|
-
sqlite: lazy(() => import("@/components/sqlite/sqlite-viewer").then((m) => ({ default: m.SqliteViewer }))),
|
|
24
|
-
postgres: lazy(() => import("@/components/postgres/postgres-viewer").then((m) => ({ default: m.PostgresViewer }))),
|
|
25
|
-
"git-diff": lazy(() => import("@/components/editor/diff-viewer").then((m) => ({ default: m.DiffViewer }))),
|
|
26
|
-
settings: lazy(() => import("@/components/settings/settings-tab").then((m) => ({ default: m.SettingsTab }))),
|
|
27
|
-
ports: lazy(() => import("@/components/ports/port-forwarding-tab").then((m) => ({ default: m.PortForwardingTab }))),
|
|
28
|
-
extension: lazy(() => import("@/components/extensions/extension-webview").then((m) => ({ default: m.ExtensionWebview }))),
|
|
29
|
-
"extension-webview": lazy(() => import("@/components/extensions/extension-webview").then((m) => ({ default: m.ExtensionWebview }))),
|
|
30
|
-
"conflict-editor": lazy(() => import("@/components/editor/conflict-editor").then((m) => ({ default: m.ConflictEditor }))),
|
|
31
|
-
};
|
|
32
|
-
|
|
33
19
|
interface EditorPanelProps {
|
|
34
20
|
panelId: string;
|
|
35
21
|
projectName: string;
|
|
@@ -43,6 +29,15 @@ export function EditorPanel({ panelId, projectName }: EditorPanelProps) {
|
|
|
43
29
|
return grid.flat().length;
|
|
44
30
|
});
|
|
45
31
|
|
|
32
|
+
// Register this panel's content area as a portal slot for TabPool.
|
|
33
|
+
// Using callback ref so registration happens synchronously when the DOM mounts,
|
|
34
|
+
// avoiding a frame delay that useEffect would cause.
|
|
35
|
+
const slotCallbackRef = useCallback((el: HTMLDivElement | null) => {
|
|
36
|
+
registerPanelSlot(panelId, el);
|
|
37
|
+
}, [panelId]);
|
|
38
|
+
// Cleanup on unmount (panelId change is handled by callback ref re-firing)
|
|
39
|
+
useEffect(() => () => registerPanelSlot(panelId, null), [panelId]);
|
|
40
|
+
|
|
46
41
|
if (!panel) return null;
|
|
47
42
|
|
|
48
43
|
return (
|
|
@@ -57,28 +52,10 @@ export function EditorPanel({ panelId, projectName }: EditorPanelProps) {
|
|
|
57
52
|
<TabBar panelId={panelId} />
|
|
58
53
|
|
|
59
54
|
<div className="flex-1 overflow-hidden relative" data-panel-drop-zone={panelId}>
|
|
60
|
-
{panel.tabs.length === 0
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const Component = TAB_COMPONENTS[tab.type];
|
|
65
|
-
const isActive = tab.id === panel.activeTabId;
|
|
66
|
-
if (!Component) {
|
|
67
|
-
return (
|
|
68
|
-
<div key={tab.id} className={isActive ? "absolute inset-0 flex items-center justify-center text-muted-foreground" : "hidden"}>
|
|
69
|
-
Unknown tab type: {tab.type}
|
|
70
|
-
</div>
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
return (
|
|
74
|
-
<div key={tab.id} className="absolute inset-0" style={isActive ? undefined : { opacity: 0, pointerEvents: "none" }}>
|
|
75
|
-
<Suspense fallback={<div className="flex items-center justify-center h-full"><Loader2 className="size-6 animate-spin text-primary" /></div>}>
|
|
76
|
-
<Component metadata={tab.metadata} tabId={tab.id} />
|
|
77
|
-
</Suspense>
|
|
78
|
-
</div>
|
|
79
|
-
);
|
|
80
|
-
})
|
|
81
|
-
)}
|
|
55
|
+
{panel.tabs.length === 0 && <EmptyPanel panelId={panelId} />}
|
|
56
|
+
{/* Always render the slot so TabPool can portal into it immediately.
|
|
57
|
+
Hidden when empty to let EmptyPanel show through. */}
|
|
58
|
+
<div ref={slotCallbackRef} className="absolute inset-0" style={panel.tabs.length === 0 ? { display: "none" } : undefined} />
|
|
82
59
|
<SplitDropOverlay panelId={panelId} />
|
|
83
60
|
</div>
|
|
84
61
|
</div>
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TabPool — persistent tab rendering with DOM reparenting.
|
|
3
|
+
*
|
|
4
|
+
* All tab components are mounted ONCE in a hidden off-screen container and
|
|
5
|
+
* never unmounted when moved between panels or split. useLayoutEffect
|
|
6
|
+
* physically moves each tab's wrapper DOM node into the correct panel slot
|
|
7
|
+
* via appendChild (which moves, not clones). Component instances, hooks,
|
|
8
|
+
* and all internal state (xterm buffer, Monaco editor, chat scroll) survive.
|
|
9
|
+
*
|
|
10
|
+
* Why not createPortal? Changing a portal's container element causes React
|
|
11
|
+
* to unmount/remount the children — defeating the purpose.
|
|
12
|
+
*/
|
|
13
|
+
import { useRef, useLayoutEffect, useSyncExternalStore, Suspense, lazy } from "react";
|
|
14
|
+
import { Loader2 } from "lucide-react";
|
|
15
|
+
import { usePanelStore } from "@/stores/panel-store";
|
|
16
|
+
import type { TabType } from "@/stores/tab-store";
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Lazy tab components (single source of truth for all tab types)
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
const TAB_COMPONENTS: Record<TabType, React.LazyExoticComponent<React.ComponentType<{ metadata?: Record<string, unknown>; tabId?: string }>>> = {
|
|
22
|
+
terminal: lazy(() => import("@/components/terminal/terminal-tab").then((m) => ({ default: m.TerminalTab }))),
|
|
23
|
+
chat: lazy(() => import("@/components/chat/chat-tab").then((m) => ({ default: m.ChatTab }))),
|
|
24
|
+
editor: lazy(() => import("@/components/editor/code-editor").then((m) => ({ default: m.CodeEditor }))),
|
|
25
|
+
database: lazy(() => import("@/components/database/database-viewer").then((m) => ({ default: m.DatabaseViewer }))),
|
|
26
|
+
sqlite: lazy(() => import("@/components/sqlite/sqlite-viewer").then((m) => ({ default: m.SqliteViewer }))),
|
|
27
|
+
postgres: lazy(() => import("@/components/postgres/postgres-viewer").then((m) => ({ default: m.PostgresViewer }))),
|
|
28
|
+
"git-diff": lazy(() => import("@/components/editor/diff-viewer").then((m) => ({ default: m.DiffViewer }))),
|
|
29
|
+
settings: lazy(() => import("@/components/settings/settings-tab").then((m) => ({ default: m.SettingsTab }))),
|
|
30
|
+
ports: lazy(() => import("@/components/ports/port-forwarding-tab").then((m) => ({ default: m.PortForwardingTab }))),
|
|
31
|
+
extension: lazy(() => import("@/components/extensions/extension-webview").then((m) => ({ default: m.ExtensionWebview }))),
|
|
32
|
+
"extension-webview": lazy(() => import("@/components/extensions/extension-webview").then((m) => ({ default: m.ExtensionWebview }))),
|
|
33
|
+
"conflict-editor": lazy(() => import("@/components/editor/conflict-editor").then((m) => ({ default: m.ConflictEditor }))),
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Slot registry — panels register their content container refs here
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
type SlotListener = () => void;
|
|
40
|
+
|
|
41
|
+
class SlotRegistry {
|
|
42
|
+
private slots = new Map<string, HTMLDivElement>();
|
|
43
|
+
private listeners = new Set<SlotListener>();
|
|
44
|
+
private version = 0;
|
|
45
|
+
|
|
46
|
+
register(panelId: string, el: HTMLDivElement | null) {
|
|
47
|
+
if (el) {
|
|
48
|
+
if (this.slots.get(panelId) === el) return;
|
|
49
|
+
this.slots.set(panelId, el);
|
|
50
|
+
} else {
|
|
51
|
+
if (!this.slots.has(panelId)) return;
|
|
52
|
+
this.slots.delete(panelId);
|
|
53
|
+
}
|
|
54
|
+
this.version++;
|
|
55
|
+
this.listeners.forEach((fn) => fn());
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
get(panelId: string): HTMLDivElement | undefined {
|
|
59
|
+
return this.slots.get(panelId);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
subscribe(fn: SlotListener): () => void {
|
|
63
|
+
this.listeners.add(fn);
|
|
64
|
+
return () => this.listeners.delete(fn);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
getVersion(): number {
|
|
68
|
+
return this.version;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const registry = new SlotRegistry();
|
|
73
|
+
|
|
74
|
+
/** Called by EditorPanel to register its content slot */
|
|
75
|
+
export function registerPanelSlot(panelId: string, el: HTMLDivElement | null) {
|
|
76
|
+
registry.register(panelId, el);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// TabPool — renders all tabs in a hidden container, reparents into slots
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
export function TabPool() {
|
|
83
|
+
// Re-render when slots change (panel mount/unmount)
|
|
84
|
+
useSyncExternalStore(
|
|
85
|
+
(cb) => registry.subscribe(cb),
|
|
86
|
+
() => registry.getVersion(),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const panels = usePanelStore((s) => s.panels);
|
|
90
|
+
const grid = usePanelStore((s) => s.grid);
|
|
91
|
+
|
|
92
|
+
// Collect all tabs across visible panels (only panels in current grid)
|
|
93
|
+
const visiblePanelIds = new Set(grid.flat());
|
|
94
|
+
const tabEntries: { tabId: string; panelId: string; type: TabType; metadata?: Record<string, unknown>; isActive: boolean }[] = [];
|
|
95
|
+
|
|
96
|
+
for (const panelId of visiblePanelIds) {
|
|
97
|
+
const panel = panels[panelId];
|
|
98
|
+
if (!panel) continue;
|
|
99
|
+
for (const tab of panel.tabs) {
|
|
100
|
+
tabEntries.push({
|
|
101
|
+
tabId: tab.id,
|
|
102
|
+
panelId,
|
|
103
|
+
type: tab.type,
|
|
104
|
+
metadata: tab.metadata,
|
|
105
|
+
isActive: tab.id === panel.activeTabId,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Stable key order — prevents React from calling insertBefore() to reorder
|
|
111
|
+
// children, which would yank reparented DOM nodes back to the hidden container
|
|
112
|
+
// and reset scroll positions / trigger resize observers.
|
|
113
|
+
tabEntries.sort((a, b) => a.tabId.localeCompare(b.tabId));
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
// Off-screen mount point. React mounts tab wrappers here, then
|
|
117
|
+
// useLayoutEffect moves them into panel slots before the browser paints.
|
|
118
|
+
<div style={{ position: "fixed", top: 0, left: 0, width: 0, height: 0, overflow: "hidden", pointerEvents: "none", visibility: "hidden" }}>
|
|
119
|
+
{tabEntries.map((entry) => (
|
|
120
|
+
<ReparentingTab
|
|
121
|
+
key={entry.tabId}
|
|
122
|
+
tabId={entry.tabId}
|
|
123
|
+
panelId={entry.panelId}
|
|
124
|
+
type={entry.type}
|
|
125
|
+
metadata={entry.metadata}
|
|
126
|
+
isActive={entry.isActive}
|
|
127
|
+
/>
|
|
128
|
+
))}
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// ReparentingTab — mounts once, physically moves between panel slots
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
interface ReparentingTabProps {
|
|
137
|
+
tabId: string;
|
|
138
|
+
panelId: string;
|
|
139
|
+
type: TabType;
|
|
140
|
+
metadata?: Record<string, unknown>;
|
|
141
|
+
isActive: boolean;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function ReparentingTab({ tabId, panelId, type, metadata, isActive }: ReparentingTabProps) {
|
|
145
|
+
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
146
|
+
const Component = TAB_COMPONENTS[type];
|
|
147
|
+
|
|
148
|
+
// Imperatively move the wrapper DOM node into the correct panel slot.
|
|
149
|
+
// appendChild on an already-mounted node moves it (DOM spec — no clone/destroy).
|
|
150
|
+
// useLayoutEffect runs before paint, so the user never sees the off-screen state.
|
|
151
|
+
// No deps — must run every render because React's reconciliation may call
|
|
152
|
+
// insertBefore() to reorder keyed children, moving reparented nodes back
|
|
153
|
+
// to the hidden container. The early-return guard keeps this cheap.
|
|
154
|
+
useLayoutEffect(() => {
|
|
155
|
+
const wrapper = wrapperRef.current;
|
|
156
|
+
const slot = registry.get(panelId);
|
|
157
|
+
if (!wrapper || !slot || wrapper.parentElement === slot) return;
|
|
158
|
+
|
|
159
|
+
// Save scroll positions — appendChild resets them during the DOM move
|
|
160
|
+
const scrollables: { el: Element; top: number; left: number }[] = [];
|
|
161
|
+
wrapper.querySelectorAll("*").forEach((el) => {
|
|
162
|
+
if (el.scrollTop || el.scrollLeft) {
|
|
163
|
+
scrollables.push({ el, top: el.scrollTop, left: el.scrollLeft });
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
slot.appendChild(wrapper);
|
|
168
|
+
|
|
169
|
+
// Restore scroll positions synchronously before paint
|
|
170
|
+
for (const { el, top, left } of scrollables) {
|
|
171
|
+
el.scrollTop = top;
|
|
172
|
+
el.scrollLeft = left;
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
if (!Component) return null;
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<div
|
|
180
|
+
ref={wrapperRef}
|
|
181
|
+
className="absolute inset-0"
|
|
182
|
+
style={isActive ? undefined : { opacity: 0, pointerEvents: "none" }}
|
|
183
|
+
data-tab-pool-id={tabId}
|
|
184
|
+
>
|
|
185
|
+
<Suspense
|
|
186
|
+
fallback={
|
|
187
|
+
<div className="flex items-center justify-center h-full">
|
|
188
|
+
<Loader2 className="size-6 animate-spin text-primary" />
|
|
189
|
+
</div>
|
|
190
|
+
}
|
|
191
|
+
>
|
|
192
|
+
<Component metadata={metadata} tabId={tabId} />
|
|
193
|
+
</Suspense>
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
@@ -300,7 +300,7 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
300
300
|
});
|
|
301
301
|
if (sessionIdRef.current && !isSessionTabActive(sessionIdRef.current)) {
|
|
302
302
|
const nType = ev.tool === "AskUserQuestion" ? "question" : "approval_request";
|
|
303
|
-
|
|
303
|
+
// Unread state added via server-side session:unread_changed broadcast — only play sound + toast here
|
|
304
304
|
playNotificationSound(nType);
|
|
305
305
|
// Persistent toast with action to navigate to the waiting session
|
|
306
306
|
const sid = sessionIdRef.current;
|
|
@@ -417,7 +417,7 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
417
417
|
setContextWindowPct(ev.contextWindowPct);
|
|
418
418
|
}
|
|
419
419
|
if (sessionIdRef.current && !isSessionTabActive(sessionIdRef.current)) {
|
|
420
|
-
|
|
420
|
+
// Unread state added via server-side session:unread_changed broadcast — only play sound here
|
|
421
421
|
playNotificationSound("done");
|
|
422
422
|
}
|
|
423
423
|
// Cancel any pending throttled sync — done handler writes final state directly
|
|
@@ -481,6 +481,13 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
481
481
|
return;
|
|
482
482
|
}
|
|
483
483
|
|
|
484
|
+
// Cross-tab/device unread sync — server broadcasts when unread state changes
|
|
485
|
+
if ((data as any).type === "session:unread_changed") {
|
|
486
|
+
const { sessionId: sid, unreadCount, unreadType, projectName: pn } = data as any;
|
|
487
|
+
useNotificationStore.getState().handleUnreadChanged(sid, unreadCount, unreadType, pn);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
|
|
484
491
|
// Dispatch global Jira events so components can listen via window events
|
|
485
492
|
if (typeof (data as any).type === "string" && (data as any).type.startsWith("jira:")) {
|
|
486
493
|
window.dispatchEvent(new CustomEvent((data as any).type, { detail: data }));
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
|
|
3
|
+
export function useDebouncedValue<T>(value: T, delayMs: number): T {
|
|
4
|
+
const [debounced, setDebounced] = useState(value);
|
|
5
|
+
useEffect(() => {
|
|
6
|
+
const timer = setTimeout(() => setDebounced(value), delayMs);
|
|
7
|
+
return () => clearTimeout(timer);
|
|
8
|
+
}, [value, delayMs]);
|
|
9
|
+
return debounced;
|
|
10
|
+
}
|