@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.
Files changed (33) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/assets/skills/ppm/SKILL.md +1 -1
  3. package/assets/skills/ppm/references/http-api.md +1 -1
  4. package/dist/web/assets/{audio-preview-Bit1BkEv.js → audio-preview-CrTLA4VQ.js} +1 -1
  5. package/dist/web/assets/{chat-tab-LuR2CwiB.js → chat-tab-DFCOXFk8.js} +3 -3
  6. package/dist/web/assets/{code-editor-DES3rcVN.js → code-editor-J864BoOW.js} +2 -2
  7. package/dist/web/assets/{conflict-editor-upKOD9uO.js → conflict-editor-BIwUtzO5.js} +1 -1
  8. package/dist/web/assets/{database-viewer-N6OCfZs9.js → database-viewer-DhawNQtp.js} +1 -1
  9. package/dist/web/assets/{diff-viewer-B1JmhayU.js → diff-viewer-nupJr1AG.js} +1 -1
  10. package/dist/web/assets/{extension-webview-BHHiMswb.js → extension-webview-BXDYtTXe.js} +1 -1
  11. package/dist/web/assets/{glide-data-grid-DBN29kPX.js → glide-data-grid-DttB_tob.js} +1 -1
  12. package/dist/web/assets/{image-preview-XYXkVEGO.js → image-preview-Dh11TP_j.js} +1 -1
  13. package/dist/web/assets/{index-EaYSB9U9.js → index-CPcnZtNl.js} +13 -13
  14. package/dist/web/assets/keybindings-store-DvBC5IaA.js +1 -0
  15. package/dist/web/assets/{markdown-renderer-DSFZBOpD.js → markdown-renderer-Bwpgzn7n.js} +1 -1
  16. package/dist/web/assets/notification-store-D1sxDh0s.js +1 -0
  17. package/dist/web/assets/{pdf-preview-Bz2JkLQ6.js → pdf-preview-CI-lrcdD.js} +1 -1
  18. package/dist/web/assets/{port-forwarding-tab-s0cGnGgx.js → port-forwarding-tab-DOEfu8ca.js} +1 -1
  19. package/dist/web/assets/{postgres-viewer-DwELE9sG.js → postgres-viewer-Bb3RwFMj.js} +1 -1
  20. package/dist/web/assets/{settings-tab-D6zXU5c_.js → settings-tab-i8KAi1LY.js} +1 -1
  21. package/dist/web/assets/{sql-query-editor-CMPsQprT.js → sql-query-editor-C3ZrhqZr.js} +1 -1
  22. package/dist/web/assets/{sqlite-viewer-BL0Z_xor.js → sqlite-viewer-Cucs41S6.js} +1 -1
  23. package/dist/web/assets/{terminal-tab-CqSN73E-.js → terminal-tab-upGE8feC.js} +1 -1
  24. package/dist/web/assets/{video-preview-Y5NIrm_u.js → video-preview-CSdxf4fH.js} +1 -1
  25. package/dist/web/index.html +1 -1
  26. package/dist/web/sw.js +1 -1
  27. package/package.json +1 -1
  28. package/src/web/app.tsx +4 -0
  29. package/src/web/components/layout/command-palette.tsx +10 -2
  30. package/src/web/components/layout/editor-panel.tsx +16 -39
  31. package/src/web/components/layout/tab-pool.tsx +196 -0
  32. package/dist/web/assets/keybindings-store-fGywATlN.js +0 -1
  33. package/dist/web/assets/notification-store-Dz9dmEg3.js +0 -1
@@ -1,5 +1,5 @@
1
- import { Suspense, lazy, useCallback } from "react";
2
- import { Loader2, Terminal, MessageSquare, FilePlus } from "lucide-react";
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
- <EmptyPanel panelId={panelId} />
62
- ) : (
63
- panel.tabs.map((tab) => {
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};