@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.
- package/dist/ppm +0 -0
- package/dist/web/assets/{button-CQ5h5gxS.js → button-CvHWF07y.js} +1 -1
- package/dist/web/assets/{chat-tab-DGDxIbGD.js → chat-tab-DJvME48K.js} +4 -4
- package/dist/web/assets/{code-editor-CYWA87cs.js → code-editor-81Tzd5aV.js} +1 -1
- package/dist/web/assets/{dialog-CCBmXo6-.js → dialog-Cn5zGuid.js} +1 -1
- package/dist/web/assets/diff-viewer-pieRctzs.js +4 -0
- package/dist/web/assets/git-graph-CWI6hxtE.js +1 -0
- package/dist/web/assets/{git-status-panel-D50lP9Ru.js → git-status-panel-CAjReViM.js} +1 -1
- package/dist/web/assets/index-BdUoflYx.css +2 -0
- package/dist/web/assets/index-CqpLusQd.js +17 -0
- package/dist/web/assets/{project-list-Ha_JrM9s.js → project-list-MAvAY2K3.js} +1 -1
- package/dist/web/assets/settings-tab-zeZrAFld.js +1 -0
- package/dist/web/assets/{terminal-tab-Cg4Pm_3X.js → terminal-tab-DlRo-KzS.js} +1 -1
- package/dist/web/index.html +7 -8
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/providers/claude-agent-sdk.ts +1 -2
- package/src/server/ws/chat.ts +211 -94
- package/src/web/app.tsx +4 -11
- package/src/web/components/chat/message-input.tsx +2 -2
- package/src/web/components/layout/draggable-tab.tsx +58 -0
- package/src/web/components/layout/editor-panel.tsx +64 -0
- package/src/web/components/layout/mobile-nav.tsx +99 -55
- package/src/web/components/layout/panel-layout.tsx +71 -0
- package/src/web/components/layout/sidebar.tsx +29 -6
- package/src/web/components/layout/split-drop-overlay.tsx +111 -0
- package/src/web/components/layout/tab-bar.tsx +60 -68
- package/src/web/hooks/use-chat.ts +31 -2
- package/src/web/hooks/use-global-keybindings.ts +8 -0
- package/src/web/hooks/use-tab-drag.ts +109 -0
- package/src/web/stores/panel-store.ts +383 -0
- package/src/web/stores/panel-utils.ts +116 -0
- package/src/web/stores/settings-store.ts +25 -17
- package/src/web/stores/tab-store.ts +32 -152
- package/dist/web/assets/diff-viewer-CX7iJZTM.js +0 -4
- package/dist/web/assets/dist-CYANqO1g.js +0 -1
- package/dist/web/assets/git-graph-BPt8qfBw.js +0 -1
- package/dist/web/assets/index-DBaFu6Af.js +0 -12
- package/dist/web/assets/index-Jhl6F2vS.css +0 -2
- package/dist/web/assets/settings-tab-C5SlVPjG.js +0 -1
- /package/dist/web/assets/{api-client-tgjN9Mx8.js → api-client-B_eCZViO.js} +0 -0
- /package/dist/web/assets/{dist-0XHv8Vwc.js → dist-B6sG2GPc.js} +0 -0
- /package/dist/web/assets/{dist-BeHIxUn0.js → dist-CBiGQxfr.js} +0 -0
- /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 (
|
|
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
|
|
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
|
+
}
|