@hienlh/ppm 0.2.17 → 0.2.19

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 (44) hide show
  1. package/dist/ppm +0 -0
  2. package/dist/web/assets/{button-CQ5h5gxS.js → button-CvHWF07y.js} +1 -1
  3. package/dist/web/assets/{chat-tab-DGDxIbGD.js → chat-tab-DJvME48K.js} +4 -4
  4. package/dist/web/assets/{code-editor-CYWA87cs.js → code-editor-81Tzd5aV.js} +1 -1
  5. package/dist/web/assets/{dialog-CCBmXo6-.js → dialog-Cn5zGuid.js} +1 -1
  6. package/dist/web/assets/diff-viewer-pieRctzs.js +4 -0
  7. package/dist/web/assets/git-graph-CWI6hxtE.js +1 -0
  8. package/dist/web/assets/{git-status-panel-D50lP9Ru.js → git-status-panel-CAjReViM.js} +1 -1
  9. package/dist/web/assets/index-BdUoflYx.css +2 -0
  10. package/dist/web/assets/index-CqpLusQd.js +17 -0
  11. package/dist/web/assets/{project-list-Ha_JrM9s.js → project-list-MAvAY2K3.js} +1 -1
  12. package/dist/web/assets/settings-tab-zeZrAFld.js +1 -0
  13. package/dist/web/assets/{terminal-tab-Cg4Pm_3X.js → terminal-tab-DlRo-KzS.js} +1 -1
  14. package/dist/web/index.html +7 -8
  15. package/dist/web/sw.js +1 -1
  16. package/package.json +1 -1
  17. package/src/providers/claude-agent-sdk.ts +1 -2
  18. package/src/server/ws/chat.ts +211 -94
  19. package/src/web/app.tsx +4 -11
  20. package/src/web/components/chat/message-input.tsx +2 -2
  21. package/src/web/components/layout/draggable-tab.tsx +58 -0
  22. package/src/web/components/layout/editor-panel.tsx +64 -0
  23. package/src/web/components/layout/mobile-nav.tsx +99 -55
  24. package/src/web/components/layout/panel-layout.tsx +71 -0
  25. package/src/web/components/layout/sidebar.tsx +29 -6
  26. package/src/web/components/layout/split-drop-overlay.tsx +111 -0
  27. package/src/web/components/layout/tab-bar.tsx +60 -68
  28. package/src/web/hooks/use-chat.ts +31 -2
  29. package/src/web/hooks/use-global-keybindings.ts +8 -0
  30. package/src/web/hooks/use-tab-drag.ts +109 -0
  31. package/src/web/stores/panel-store.ts +383 -0
  32. package/src/web/stores/panel-utils.ts +116 -0
  33. package/src/web/stores/settings-store.ts +25 -17
  34. package/src/web/stores/tab-store.ts +32 -152
  35. package/dist/web/assets/diff-viewer-CX7iJZTM.js +0 -4
  36. package/dist/web/assets/dist-CYANqO1g.js +0 -1
  37. package/dist/web/assets/git-graph-BPt8qfBw.js +0 -1
  38. package/dist/web/assets/index-DBaFu6Af.js +0 -12
  39. package/dist/web/assets/index-Jhl6F2vS.css +0 -2
  40. package/dist/web/assets/settings-tab-C5SlVPjG.js +0 -1
  41. /package/dist/web/assets/{api-client-tgjN9Mx8.js → api-client-B_eCZViO.js} +0 -0
  42. /package/dist/web/assets/{dist-0XHv8Vwc.js → dist-B6sG2GPc.js} +0 -0
  43. /package/dist/web/assets/{dist-BeHIxUn0.js → dist-CBiGQxfr.js} +0 -0
  44. /package/dist/web/assets/{utils-D6me7KDg.js → utils-61GRB9Cb.js} +0 -0
@@ -43,6 +43,7 @@ export function useChat(sessionId: string | null, providerId = "claude-sdk", pro
43
43
  const isStreamingRef = useRef(false);
44
44
  const pendingMessageRef = useRef<string | null>(null);
45
45
  const sendRef = useRef<(data: string) => void>(() => {});
46
+ const refetchRef = useRef<(() => void) | null>(null);
46
47
 
47
48
  const handleMessage = useCallback((event: MessageEvent) => {
48
49
  let data: ChatWsServerMessage;
@@ -55,12 +56,32 @@ export function useChat(sessionId: string | null, providerId = "claude-sdk", pro
55
56
  // Ignore keepalive pings
56
57
  if ((data as any).type === "ping") return;
57
58
 
58
- // Handle connected event (custom, not in type)
59
+ // Handle connected event (new session)
59
60
  if ((data as any).type === "connected") {
60
61
  setIsConnected(true);
61
62
  return;
62
63
  }
63
64
 
65
+ // Handle status event (FE reconnected to existing session)
66
+ if ((data as any).type === "status") {
67
+ setIsConnected(true);
68
+ const status = data as any;
69
+ if (status.isStreaming) {
70
+ isStreamingRef.current = true;
71
+ setIsStreaming(true);
72
+ }
73
+ if (status.pendingApproval) {
74
+ setPendingApproval({
75
+ requestId: status.pendingApproval.requestId,
76
+ tool: status.pendingApproval.tool,
77
+ input: status.pendingApproval.input,
78
+ });
79
+ }
80
+ // Refetch history to catch up on events missed during disconnect
81
+ refetchRef.current?.();
82
+ return;
83
+ }
84
+
64
85
  switch (data.type) {
65
86
  case "text": {
66
87
  streamingContentRef.current += data.content;
@@ -379,10 +400,12 @@ export function useChat(sessionId: string | null, providerId = "claude-sdk", pro
379
400
  const reconnect = useCallback(() => {
380
401
  setIsConnected(false);
381
402
  wsReconnect();
403
+ // Refetch history on manual reconnect to catch up on missed events
404
+ refetchRef.current?.();
382
405
  }, [wsReconnect]);
383
406
 
384
407
  const refetchMessages = useCallback(() => {
385
- if (!sessionId || !projectName || isStreamingRef.current) return;
408
+ if (!sessionId || !projectName) return;
386
409
  setMessagesLoading(true);
387
410
  fetch(`${projectUrl(projectName)}/chat/sessions/${sessionId}/messages?providerId=${providerId}`, {
388
411
  headers: { Authorization: `Bearer ${getAuthToken()}` },
@@ -391,12 +414,18 @@ export function useChat(sessionId: string | null, providerId = "claude-sdk", pro
391
414
  .then((json: any) => {
392
415
  if (json.ok && Array.isArray(json.data) && json.data.length > 0) {
393
416
  setMessages(json.data);
417
+ // Reset streaming content refs so live tokens append cleanly after history
418
+ streamingContentRef.current = "";
419
+ streamingEventsRef.current = [];
394
420
  }
395
421
  })
396
422
  .catch(() => {})
397
423
  .finally(() => setMessagesLoading(false));
398
424
  }, [sessionId, providerId, projectName]);
399
425
 
426
+ // Keep refetchRef in sync so handleMessage (status event) can trigger refetch
427
+ refetchRef.current = refetchMessages;
428
+
400
429
  return {
401
430
  messages,
402
431
  messagesLoading,
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useState, useCallback } from "react";
2
2
  import { useTabStore } from "@/stores/tab-store";
3
+ import { useSettingsStore } from "@/stores/settings-store";
3
4
 
4
5
  /**
5
6
  * Global keyboard shortcuts.
@@ -29,6 +30,13 @@ export function useGlobalKeybindings() {
29
30
  // Keydown shortcuts
30
31
  if (e.type !== "keydown") return;
31
32
 
33
+ // Cmd/Ctrl+B → Toggle sidebar
34
+ if (e.key === "b" && (e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey) {
35
+ e.preventDefault();
36
+ useSettingsStore.getState().toggleSidebar();
37
+ return;
38
+ }
39
+
32
40
  // Alt+] / Alt+[ → Cycle tabs
33
41
  if (e.altKey && !e.ctrlKey && !e.metaKey && (e.key === "]" || e.key === "[")) {
34
42
  e.preventDefault();
@@ -0,0 +1,109 @@
1
+ import { useRef, useState, useCallback, useSyncExternalStore } from "react";
2
+ import { usePanelStore } from "@/stores/panel-store";
3
+
4
+ export const TAB_DRAG_TYPE = "application/ppm-tab";
5
+
6
+ export interface DragPayload {
7
+ tabId: string;
8
+ panelId: string;
9
+ }
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Global drag state — lets overlays know a tab drag is in progress
13
+ // ---------------------------------------------------------------------------
14
+ let _dragging = false;
15
+ const _listeners = new Set<() => void>();
16
+
17
+ function setDragging(v: boolean) {
18
+ if (_dragging === v) return;
19
+ _dragging = v;
20
+ _listeners.forEach((fn) => fn());
21
+ }
22
+
23
+ export function useIsDraggingTab(): boolean {
24
+ return useSyncExternalStore(
25
+ (cb) => { _listeners.add(cb); return () => _listeners.delete(cb); },
26
+ () => _dragging,
27
+ );
28
+ }
29
+
30
+ /** Call from any drop handler to clear global drag state */
31
+ export function clearDragging() {
32
+ setDragging(false);
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Hook for tab bar DnD
37
+ // ---------------------------------------------------------------------------
38
+ export function useTabDrag(panelId: string) {
39
+ const [dropIndex, setDropIndex] = useState<number | null>(null);
40
+ const dragOverRef = useRef<string | null>(null);
41
+
42
+ const handleDragStart = useCallback(
43
+ (e: React.DragEvent, tabId: string) => {
44
+ const payload: DragPayload = { tabId, panelId };
45
+ e.dataTransfer.setData(TAB_DRAG_TYPE, JSON.stringify(payload));
46
+ e.dataTransfer.effectAllowed = "move";
47
+ // Delay so browser captures the drag image first
48
+ requestAnimationFrame(() => setDragging(true));
49
+ },
50
+ [panelId],
51
+ );
52
+
53
+ const handleDragOver = useCallback(
54
+ (e: React.DragEvent, tabId: string, tabIndex: number) => {
55
+ if (!e.dataTransfer.types.includes(TAB_DRAG_TYPE)) return;
56
+ e.preventDefault();
57
+ e.dataTransfer.dropEffect = "move";
58
+
59
+ if (dragOverRef.current === tabId) return;
60
+ dragOverRef.current = tabId;
61
+
62
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
63
+ const midX = rect.left + rect.width / 2;
64
+ setDropIndex(e.clientX < midX ? tabIndex : tabIndex + 1);
65
+ },
66
+ [],
67
+ );
68
+
69
+ const handleDragOverBar = useCallback(
70
+ (e: React.DragEvent) => {
71
+ if (!e.dataTransfer.types.includes(TAB_DRAG_TYPE)) return;
72
+ e.preventDefault();
73
+ e.dataTransfer.dropEffect = "move";
74
+ },
75
+ [],
76
+ );
77
+
78
+ const handleDrop = useCallback(
79
+ (e: React.DragEvent) => {
80
+ e.preventDefault();
81
+ setDragging(false);
82
+ const raw = e.dataTransfer.getData(TAB_DRAG_TYPE);
83
+ if (!raw) return;
84
+
85
+ try {
86
+ const payload = JSON.parse(raw) as DragPayload;
87
+ const store = usePanelStore.getState();
88
+
89
+ if (payload.panelId === panelId) {
90
+ if (dropIndex !== null) store.reorderTab(payload.tabId, panelId, dropIndex);
91
+ } else {
92
+ store.moveTab(payload.tabId, payload.panelId, panelId, dropIndex ?? undefined);
93
+ }
94
+ } catch { /* ignore */ }
95
+
96
+ setDropIndex(null);
97
+ dragOverRef.current = null;
98
+ },
99
+ [panelId, dropIndex],
100
+ );
101
+
102
+ const handleDragEnd = useCallback(() => {
103
+ setDragging(false);
104
+ setDropIndex(null);
105
+ dragOverRef.current = null;
106
+ }, []);
107
+
108
+ return { dropIndex, handleDragStart, handleDragOver, handleDragOverBar, handleDrop, handleDragEnd };
109
+ }
@@ -0,0 +1,383 @@
1
+ import { create } from "zustand";
2
+ import { randomId } from "@/lib/utils";
3
+ import type { Tab, TabType } from "./tab-store";
4
+ import {
5
+ type Panel,
6
+ type PanelLayout,
7
+ createPanel,
8
+ gridAddColumn,
9
+ gridAddRow,
10
+ gridRemovePanel,
11
+ findPanelPosition,
12
+ maxColumns,
13
+ MAX_ROWS,
14
+ savePanelLayout,
15
+ loadPanelLayout,
16
+ } from "./panel-utils";
17
+
18
+ /** Tab types that can only have 1 instance per project */
19
+ const SINGLETON_TYPES = new Set<TabType>(["git-status", "git-graph", "settings", "projects"]);
20
+
21
+ function generateTabId(): string {
22
+ return `tab-${randomId()}`;
23
+ }
24
+
25
+ function pushHistory(history: string[], id: string): string[] {
26
+ const filtered = history.filter((h) => h !== id);
27
+ filtered.push(id);
28
+ if (filtered.length > 50) filtered.shift();
29
+ return filtered;
30
+ }
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Store interface
34
+ // ---------------------------------------------------------------------------
35
+ export interface PanelStore {
36
+ panels: Record<string, Panel>;
37
+ grid: string[][];
38
+ focusedPanelId: string;
39
+ currentProject: string | null;
40
+
41
+ // Project lifecycle
42
+ switchProject: (projectName: string) => void;
43
+
44
+ // Panel focus
45
+ setFocusedPanel: (panelId: string) => void;
46
+
47
+ // Tab operations (operate on focused panel by default)
48
+ openTab: (tab: Omit<Tab, "id">, panelId?: string) => string;
49
+ closeTab: (tabId: string, panelId?: string) => void;
50
+ setActiveTab: (tabId: string, panelId?: string) => void;
51
+ updateTab: (tabId: string, updates: Partial<Omit<Tab, "id">>) => void;
52
+
53
+ // Panel operations
54
+ reorderTab: (tabId: string, panelId: string, newIndex: number) => void;
55
+ moveTab: (tabId: string, fromPanelId: string, toPanelId: string, insertIndex?: number) => void;
56
+ splitPanel: (direction: "left" | "right" | "up" | "down", tabId: string, sourcePanelId: string, targetPanelId?: string) => boolean;
57
+ closePanel: (panelId: string) => void;
58
+
59
+ // Helpers
60
+ getPanelForTab: (tabId: string) => Panel | undefined;
61
+ isMobile: () => boolean;
62
+ }
63
+
64
+ function defaultLayout(): { panels: Record<string, Panel>; grid: string[][]; focusedPanelId: string } {
65
+ const panel = createPanel();
66
+ return { panels: { [panel.id]: panel }, grid: [[panel.id]], focusedPanelId: panel.id };
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Store
71
+ // ---------------------------------------------------------------------------
72
+ export const usePanelStore = create<PanelStore>()((set, get) => {
73
+ function persist() {
74
+ const { currentProject, panels, grid, focusedPanelId } = get();
75
+ if (currentProject) savePanelLayout(currentProject, { panels, grid, focusedPanelId });
76
+ }
77
+
78
+ function findPanel(tabId: string): Panel | undefined {
79
+ return Object.values(get().panels).find((p) => p.tabs.some((t) => t.id === tabId));
80
+ }
81
+
82
+ function resolvePanel(panelId?: string): string {
83
+ return panelId ?? get().focusedPanelId;
84
+ }
85
+
86
+ return {
87
+ ...defaultLayout(),
88
+ currentProject: null,
89
+
90
+ switchProject: (projectName) => {
91
+ const { currentProject } = get();
92
+ if (currentProject) persist();
93
+
94
+ const loaded = loadPanelLayout(projectName);
95
+ if (loaded && Object.keys(loaded.panels).length > 0) {
96
+ set({ currentProject: projectName, ...loaded });
97
+ } else {
98
+ const p = createPanel();
99
+ const defaultTab: Tab = {
100
+ id: generateTabId(), type: "projects", title: "Projects", projectId: null, closable: true,
101
+ };
102
+ p.tabs = [defaultTab];
103
+ p.activeTabId = defaultTab.id;
104
+ p.tabHistory = [defaultTab.id];
105
+ const layout = { panels: { [p.id]: p }, grid: [[p.id]], focusedPanelId: p.id };
106
+ savePanelLayout(projectName, layout);
107
+ set({ currentProject: projectName, ...layout });
108
+ }
109
+ },
110
+
111
+ setFocusedPanel: (panelId) => {
112
+ if (get().panels[panelId]) set({ focusedPanelId: panelId });
113
+ },
114
+
115
+ openTab: (tabDef, panelId?) => {
116
+ const pid = resolvePanel(panelId);
117
+ const panel = get().panels[pid];
118
+ if (!panel) return "";
119
+
120
+ // Singleton check across ALL panels
121
+ if (SINGLETON_TYPES.has(tabDef.type)) {
122
+ for (const p of Object.values(get().panels)) {
123
+ const existing = p.tabs.find((t) => t.type === tabDef.type && t.projectId === tabDef.projectId);
124
+ if (existing) {
125
+ set((s) => ({
126
+ focusedPanelId: p.id,
127
+ panels: {
128
+ ...s.panels,
129
+ [p.id]: {
130
+ ...p,
131
+ activeTabId: existing.id,
132
+ tabHistory: pushHistory(p.tabHistory, existing.id),
133
+ },
134
+ },
135
+ }));
136
+ persist();
137
+ return existing.id;
138
+ }
139
+ }
140
+ }
141
+
142
+ const id = generateTabId();
143
+ const tab: Tab = { ...tabDef, id };
144
+ set((s) => {
145
+ const p = s.panels[pid]!;
146
+ return {
147
+ focusedPanelId: pid,
148
+ panels: {
149
+ ...s.panels,
150
+ [pid]: {
151
+ ...p,
152
+ tabs: [...p.tabs, tab],
153
+ activeTabId: id,
154
+ tabHistory: pushHistory(p.tabHistory, id),
155
+ },
156
+ },
157
+ };
158
+ });
159
+ persist();
160
+ return id;
161
+ },
162
+
163
+ closeTab: (tabId, panelId?) => {
164
+ const panel = panelId ? get().panels[panelId] : findPanel(tabId);
165
+ if (!panel) return;
166
+ const pid = panel.id;
167
+
168
+ set((s) => {
169
+ const p = s.panels[pid]!;
170
+ const newTabs = p.tabs.filter((t) => t.id !== tabId);
171
+ const newHistory = p.tabHistory.filter((h) => h !== tabId);
172
+ let newActive = p.activeTabId;
173
+ if (p.activeTabId === tabId) {
174
+ const prevId = newHistory.length > 0 ? newHistory[newHistory.length - 1] : null;
175
+ newActive = prevId && newTabs.some((t) => t.id === prevId)
176
+ ? prevId
177
+ : newTabs[newTabs.length - 1]?.id ?? null;
178
+ }
179
+
180
+ // Auto-close panel if empty and not the last one
181
+ const panelIds = Object.keys(s.panels);
182
+ if (newTabs.length === 0 && panelIds.length > 1) {
183
+ const { [pid]: _, ...rest } = s.panels;
184
+ const newGrid = gridRemovePanel(s.grid, pid);
185
+ const newFocused = s.focusedPanelId === pid ? Object.keys(rest)[0]! : s.focusedPanelId;
186
+ return { panels: rest, grid: newGrid, focusedPanelId: newFocused };
187
+ }
188
+
189
+ return {
190
+ panels: { ...s.panels, [pid]: { ...p, tabs: newTabs, activeTabId: newActive, tabHistory: newHistory } },
191
+ };
192
+ });
193
+ persist();
194
+ },
195
+
196
+ setActiveTab: (tabId, panelId?) => {
197
+ const panel = panelId ? get().panels[panelId] : findPanel(tabId);
198
+ if (!panel) return;
199
+ const pid = panel.id;
200
+ set((s) => {
201
+ const p = s.panels[pid]!;
202
+ return {
203
+ focusedPanelId: pid,
204
+ panels: { ...s.panels, [pid]: { ...p, activeTabId: tabId, tabHistory: pushHistory(p.tabHistory, tabId) } },
205
+ };
206
+ });
207
+ persist();
208
+ },
209
+
210
+ updateTab: (tabId, updates) => {
211
+ const panel = findPanel(tabId);
212
+ if (!panel) return;
213
+ set((s) => ({
214
+ panels: {
215
+ ...s.panels,
216
+ [panel.id]: { ...panel, tabs: panel.tabs.map((t) => (t.id === tabId ? { ...t, ...updates } : t)) },
217
+ },
218
+ }));
219
+ persist();
220
+ },
221
+
222
+ reorderTab: (tabId, panelId, newIndex) => {
223
+ const panel = get().panels[panelId];
224
+ if (!panel) return;
225
+ const oldIndex = panel.tabs.findIndex((t) => t.id === tabId);
226
+ if (oldIndex === -1 || oldIndex === newIndex) return;
227
+ const newTabs = [...panel.tabs];
228
+ const [moved] = newTabs.splice(oldIndex, 1);
229
+ newTabs.splice(newIndex, 0, moved!);
230
+ set((s) => ({ panels: { ...s.panels, [panelId]: { ...panel, tabs: newTabs } } }));
231
+ persist();
232
+ },
233
+
234
+ moveTab: (tabId, fromPanelId, toPanelId, insertIndex?) => {
235
+ if (fromPanelId === toPanelId) return;
236
+ const from = get().panels[fromPanelId];
237
+ const to = get().panels[toPanelId];
238
+ if (!from || !to) return;
239
+
240
+ const tab = from.tabs.find((t) => t.id === tabId);
241
+ if (!tab) return;
242
+
243
+ const fromTabs = from.tabs.filter((t) => t.id !== tabId);
244
+ const fromHistory = from.tabHistory.filter((h) => h !== tabId);
245
+ const fromActive = from.activeTabId === tabId
246
+ ? (fromHistory[fromHistory.length - 1] ?? fromTabs[fromTabs.length - 1]?.id ?? null)
247
+ : from.activeTabId;
248
+
249
+ const toTabs = [...to.tabs];
250
+ if (insertIndex !== undefined) toTabs.splice(insertIndex, 0, tab);
251
+ else toTabs.push(tab);
252
+
253
+ set((s) => {
254
+ const panelIds = Object.keys(s.panels);
255
+ // Auto-close empty source panel if not last
256
+ if (fromTabs.length === 0 && panelIds.length > 1) {
257
+ const { [fromPanelId]: _, ...rest } = s.panels;
258
+ return {
259
+ panels: {
260
+ ...rest,
261
+ [toPanelId]: { ...to, tabs: toTabs, activeTabId: tabId, tabHistory: pushHistory(to.tabHistory, tabId) },
262
+ },
263
+ grid: gridRemovePanel(s.grid, fromPanelId),
264
+ focusedPanelId: toPanelId,
265
+ };
266
+ }
267
+
268
+ return {
269
+ focusedPanelId: toPanelId,
270
+ panels: {
271
+ ...s.panels,
272
+ [fromPanelId]: { ...from, tabs: fromTabs, activeTabId: fromActive, tabHistory: fromHistory },
273
+ [toPanelId]: { ...to, tabs: toTabs, activeTabId: tabId, tabHistory: pushHistory(to.tabHistory, tabId) },
274
+ },
275
+ };
276
+ });
277
+ persist();
278
+ },
279
+
280
+ splitPanel: (direction, tabId, sourcePanelId, targetPanelId?) => {
281
+ const { grid, panels } = get();
282
+ const mobile = get().isMobile();
283
+ const source = panels[sourcePanelId];
284
+ if (!source) return false;
285
+
286
+ const tab = source.tabs.find((t) => t.id === tabId);
287
+ if (!tab) return false;
288
+
289
+ // Use target panel's position for grid insertion (where the drop happened)
290
+ const positionPanelId = targetPanelId ?? sourcePanelId;
291
+ const pos = findPanelPosition(grid, positionPanelId);
292
+ if (!pos) return false;
293
+
294
+ // Check constraints
295
+ const isHorizontal = direction === "left" || direction === "right";
296
+ const isVertical = direction === "up" || direction === "down";
297
+ if (isHorizontal && grid.length >= maxColumns(mobile)) return false;
298
+ if (isVertical && (grid[pos.col]?.length ?? 0) >= MAX_ROWS) return false;
299
+
300
+ const newPanel = createPanel([tab], tab.id);
301
+ newPanel.tabHistory = [tab.id];
302
+
303
+ // Remove tab from source
304
+ const srcTabs = source.tabs.filter((t) => t.id !== tabId);
305
+ const srcHistory = source.tabHistory.filter((h) => h !== tabId);
306
+ const srcActive = source.activeTabId === tabId
307
+ ? (srcHistory[srcHistory.length - 1] ?? srcTabs[srcTabs.length - 1]?.id ?? null)
308
+ : source.activeTabId;
309
+
310
+ let newGrid: string[][];
311
+ if (isHorizontal) {
312
+ newGrid = [...grid];
313
+ const insertCol = direction === "right" ? pos.col + 1 : pos.col;
314
+ newGrid.splice(insertCol, 0, [newPanel.id]);
315
+ } else {
316
+ // up: insert before current row, down: insert after
317
+ newGrid = grid.map((col, c) => {
318
+ if (c !== pos.col) return col;
319
+ const newCol = [...col];
320
+ const insertRow = direction === "down" ? pos.row + 1 : pos.row;
321
+ newCol.splice(insertRow, 0, newPanel.id);
322
+ return newCol;
323
+ });
324
+ }
325
+
326
+ set((s) => {
327
+ const panelIds = Object.keys(s.panels);
328
+ let updatedPanels = {
329
+ ...s.panels,
330
+ [newPanel.id]: newPanel,
331
+ };
332
+
333
+ // If source is now empty and not last panel, remove it
334
+ if (srcTabs.length === 0 && panelIds.length > 1) {
335
+ const { [sourcePanelId]: _, ...rest } = updatedPanels;
336
+ updatedPanels = rest;
337
+ newGrid = gridRemovePanel(newGrid, sourcePanelId);
338
+ } else {
339
+ updatedPanels[sourcePanelId] = { ...source, tabs: srcTabs, activeTabId: srcActive, tabHistory: srcHistory };
340
+ }
341
+
342
+ return { panels: updatedPanels, grid: newGrid, focusedPanelId: newPanel.id };
343
+ });
344
+ persist();
345
+ return true;
346
+ },
347
+
348
+ closePanel: (panelId) => {
349
+ const { panels, grid } = get();
350
+ if (Object.keys(panels).length <= 1) return;
351
+
352
+ const panel = panels[panelId];
353
+ if (!panel) return;
354
+
355
+ // Find neighbor to merge tabs into
356
+ const pos = findPanelPosition(grid, panelId);
357
+ const allIds = grid.flat();
358
+ const idx = allIds.indexOf(panelId);
359
+ const neighborId = idx > 0 ? allIds[idx - 1]! : allIds[1]!;
360
+ const neighbor = panels[neighborId];
361
+ if (!neighbor) return;
362
+
363
+ set((s) => {
364
+ const { [panelId]: _, ...rest } = s.panels;
365
+ const mergedTabs = [...neighbor.tabs, ...panel.tabs];
366
+ const mergedActive = neighbor.activeTabId ?? panel.activeTabId;
367
+ return {
368
+ panels: {
369
+ ...rest,
370
+ [neighborId]: { ...neighbor, tabs: mergedTabs, activeTabId: mergedActive, tabHistory: [...neighbor.tabHistory, ...panel.tabHistory] },
371
+ },
372
+ grid: gridRemovePanel(s.grid, panelId),
373
+ focusedPanelId: neighborId,
374
+ };
375
+ });
376
+ persist();
377
+ },
378
+
379
+ getPanelForTab: (tabId) => findPanel(tabId),
380
+
381
+ isMobile: () => typeof window !== "undefined" && window.innerWidth < 768,
382
+ };
383
+ });
@@ -0,0 +1,116 @@
1
+ import { randomId } from "@/lib/utils";
2
+ import type { Tab } from "./tab-store";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Panel types
6
+ // ---------------------------------------------------------------------------
7
+ export interface Panel {
8
+ id: string;
9
+ tabs: Tab[];
10
+ activeTabId: string | null;
11
+ tabHistory: string[];
12
+ }
13
+
14
+ export interface PanelLayout {
15
+ panels: Record<string, Panel>;
16
+ /** grid[col][row] = panelId */
17
+ grid: string[][];
18
+ focusedPanelId: string;
19
+ }
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Helpers
23
+ // ---------------------------------------------------------------------------
24
+ export function generatePanelId(): string {
25
+ return `panel-${randomId()}`;
26
+ }
27
+
28
+ export function createPanel(tabs: Tab[] = [], activeTabId: string | null = null): Panel {
29
+ return {
30
+ id: generatePanelId(),
31
+ tabs,
32
+ activeTabId,
33
+ tabHistory: activeTabId ? [activeTabId] : [],
34
+ };
35
+ }
36
+
37
+ /** Max columns: 3 desktop, 1 mobile */
38
+ export function maxColumns(isMobile: boolean): number {
39
+ return isMobile ? 1 : 3;
40
+ }
41
+
42
+ /** Max rows per column */
43
+ export const MAX_ROWS = 2;
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Grid manipulation
47
+ // ---------------------------------------------------------------------------
48
+ export function gridAddColumn(grid: string[][], panelId: string): string[][] {
49
+ return [...grid, [panelId]];
50
+ }
51
+
52
+ export function gridAddRow(grid: string[][], colIndex: number, panelId: string): string[][] {
53
+ return grid.map((col, i) => (i === colIndex ? [...col, panelId] : col));
54
+ }
55
+
56
+ export function gridRemovePanel(grid: string[][], panelId: string): string[][] {
57
+ return grid
58
+ .map((col) => col.filter((id) => id !== panelId))
59
+ .filter((col) => col.length > 0);
60
+ }
61
+
62
+ export function findPanelPosition(grid: string[][], panelId: string): { col: number; row: number } | null {
63
+ for (let c = 0; c < grid.length; c++) {
64
+ const r = grid[c]!.indexOf(panelId);
65
+ if (r !== -1) return { col: c, row: r };
66
+ }
67
+ return null;
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Persistence
72
+ // ---------------------------------------------------------------------------
73
+ const STORAGE_PREFIX = "ppm-panels-";
74
+ const OLD_STORAGE_PREFIX = "ppm-tabs-";
75
+
76
+ function storageKey(projectName: string): string {
77
+ return `${STORAGE_PREFIX}${projectName}`;
78
+ }
79
+
80
+ export function savePanelLayout(projectName: string, layout: PanelLayout): void {
81
+ try {
82
+ localStorage.setItem(storageKey(projectName), JSON.stringify(layout));
83
+ } catch { /* ignore */ }
84
+ }
85
+
86
+ export function loadPanelLayout(projectName: string): PanelLayout | null {
87
+ try {
88
+ const raw = localStorage.getItem(storageKey(projectName));
89
+ if (raw) return JSON.parse(raw) as PanelLayout;
90
+ } catch { /* ignore */ }
91
+
92
+ // Migrate from old tab-store format
93
+ return migrateOldTabStore(projectName);
94
+ }
95
+
96
+ function migrateOldTabStore(projectName: string): PanelLayout | null {
97
+ try {
98
+ const raw = localStorage.getItem(`${OLD_STORAGE_PREFIX}${projectName}`);
99
+ if (!raw) return null;
100
+ const old = JSON.parse(raw) as { tabs: Tab[]; activeTabId: string | null };
101
+ if (!old.tabs?.length) return null;
102
+
103
+ const panel = createPanel(old.tabs, old.activeTabId);
104
+ const layout: PanelLayout = {
105
+ panels: { [panel.id]: panel },
106
+ grid: [[panel.id]],
107
+ focusedPanelId: panel.id,
108
+ };
109
+ // Save new format and clean old
110
+ savePanelLayout(projectName, layout);
111
+ localStorage.removeItem(`${OLD_STORAGE_PREFIX}${projectName}`);
112
+ return layout;
113
+ } catch {
114
+ return null;
115
+ }
116
+ }