@hienlh/ppm 0.13.21 → 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 +5 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +1 -1
- package/dist/web/assets/{audio-preview-Bit1BkEv.js → audio-preview-CrTLA4VQ.js} +1 -1
- package/dist/web/assets/{chat-tab-LuR2CwiB.js → chat-tab-DFCOXFk8.js} +3 -3
- package/dist/web/assets/{code-editor-DES3rcVN.js → code-editor-J864BoOW.js} +2 -2
- package/dist/web/assets/{conflict-editor-upKOD9uO.js → conflict-editor-BIwUtzO5.js} +1 -1
- package/dist/web/assets/{database-viewer-N6OCfZs9.js → database-viewer-DhawNQtp.js} +1 -1
- package/dist/web/assets/{diff-viewer-B1JmhayU.js → diff-viewer-nupJr1AG.js} +1 -1
- package/dist/web/assets/{extension-webview-BHHiMswb.js → extension-webview-BXDYtTXe.js} +1 -1
- package/dist/web/assets/{glide-data-grid-DBN29kPX.js → glide-data-grid-DttB_tob.js} +1 -1
- package/dist/web/assets/{image-preview-XYXkVEGO.js → image-preview-Dh11TP_j.js} +1 -1
- package/dist/web/assets/{index-EaYSB9U9.js → index-CPcnZtNl.js} +13 -13
- package/dist/web/assets/keybindings-store-DvBC5IaA.js +1 -0
- package/dist/web/assets/{markdown-renderer-DSFZBOpD.js → markdown-renderer-Bwpgzn7n.js} +1 -1
- package/dist/web/assets/notification-store-D1sxDh0s.js +1 -0
- package/dist/web/assets/{pdf-preview-Bz2JkLQ6.js → pdf-preview-CI-lrcdD.js} +1 -1
- package/dist/web/assets/{port-forwarding-tab-s0cGnGgx.js → port-forwarding-tab-DOEfu8ca.js} +1 -1
- package/dist/web/assets/{postgres-viewer-DwELE9sG.js → postgres-viewer-Bb3RwFMj.js} +1 -1
- package/dist/web/assets/{settings-tab-D6zXU5c_.js → settings-tab-i8KAi1LY.js} +1 -1
- package/dist/web/assets/{sql-query-editor-CMPsQprT.js → sql-query-editor-C3ZrhqZr.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-BL0Z_xor.js → sqlite-viewer-Cucs41S6.js} +1 -1
- package/dist/web/assets/{terminal-tab-CqSN73E-.js → terminal-tab-upGE8feC.js} +1 -1
- package/dist/web/assets/{video-preview-Y5NIrm_u.js → video-preview-CSdxf4fH.js} +1 -1
- package/dist/web/index.html +1 -1
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/web/app.tsx +4 -0
- 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/dist/web/assets/keybindings-store-fGywATlN.js +0 -1
- package/dist/web/assets/notification-store-Dz9dmEg3.js +0 -1
|
@@ -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
|
+
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import"./vendor-markdown-0Mxgxy0L.js";import"./api-client-DIhJ5qVW.js";import{k as e}from"./index-EaYSB9U9.js";export{e as useKeybindingsStore};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import"./vendor-markdown-0Mxgxy0L.js";import"./api-client-DIhJ5qVW.js";import{x as e}from"./index-EaYSB9U9.js";export{e as useNotificationStore};
|