@hienlh/ppm 0.2.16 → 0.2.18
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/bun.lock +30 -3
- package/dist/ppm +0 -0
- package/dist/web/assets/{button-CQ5h5gxS.js → button-CvHWF07y.js} +1 -1
- package/dist/web/assets/{chat-tab-Cpj7_-mS.js → chat-tab-Cbc-uzKV.js} +4 -4
- package/dist/web/assets/{code-editor-BauDrXcB.js → code-editor-B9e5P-DN.js} +1 -1
- package/dist/web/assets/{dialog-CCBmXo6-.js → dialog-Cn5zGuid.js} +1 -1
- package/dist/web/assets/diff-viewer-B0iMQ1Qf.js +4 -0
- package/dist/web/assets/git-graph-D3ls9-HA.js +1 -0
- package/dist/web/assets/{git-status-panel-B_iCL1Ge.js → git-status-panel-UB0AyOdX.js} +1 -1
- package/dist/web/assets/index-BdUoflYx.css +2 -0
- package/dist/web/assets/index-DLIV9ojh.js +17 -0
- package/dist/web/assets/{project-list-7ReggIMy.js → project-list-D6oBUMd8.js} +1 -1
- package/dist/web/assets/settings-tab-DmTDAK9n.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 +3 -2
- package/src/providers/claude-agent-sdk.ts +1 -2
- package/src/server/index.ts +2 -0
- package/src/server/routes/push.ts +54 -0
- package/src/server/ws/chat.ts +211 -87
- package/src/services/push-notification.service.ts +118 -0
- package/src/types/config.ts +7 -0
- package/src/web/app.tsx +4 -11
- 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/components/settings/settings-tab.tsx +45 -1
- package/src/web/hooks/use-chat.ts +31 -2
- package/src/web/hooks/use-global-keybindings.ts +8 -0
- package/src/web/hooks/use-push-notification.ts +96 -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/src/web/sw.ts +52 -0
- package/vite.config.ts +4 -11
- package/dist/web/assets/diff-viewer-CGIQRv_l.js +0 -4
- package/dist/web/assets/dist-CYANqO1g.js +0 -1
- package/dist/web/assets/git-graph-D4IUX9-7.js +0 -1
- package/dist/web/assets/index-DOHQ7GlD.js +0 -12
- package/dist/web/assets/index-Jhl6F2vS.css +0 -2
- package/dist/web/assets/settings-tab-Ceuow24i.js +0 -1
- package/dist/web/workbox-3e722498.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
|
@@ -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
|
+
}
|
|
@@ -6,33 +6,32 @@ const STORAGE_KEY = "ppm-settings";
|
|
|
6
6
|
|
|
7
7
|
interface SettingsState {
|
|
8
8
|
theme: Theme;
|
|
9
|
+
sidebarCollapsed: boolean;
|
|
9
10
|
deviceName: string | null;
|
|
10
11
|
version: string | null;
|
|
11
12
|
setTheme: (theme: Theme) => void;
|
|
13
|
+
toggleSidebar: () => void;
|
|
12
14
|
fetchServerInfo: () => Promise<void>;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
|
|
17
|
+
interface PersistedSettings {
|
|
18
|
+
theme?: Theme;
|
|
19
|
+
sidebarCollapsed?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function loadPersistedSettings(): PersistedSettings {
|
|
16
23
|
try {
|
|
17
24
|
const stored = localStorage.getItem(STORAGE_KEY);
|
|
18
|
-
if (stored)
|
|
19
|
-
const parsed = JSON.parse(stored) as { theme?: Theme };
|
|
20
|
-
if (
|
|
21
|
-
parsed.theme === "light" ||
|
|
22
|
-
parsed.theme === "dark" ||
|
|
23
|
-
parsed.theme === "system"
|
|
24
|
-
) {
|
|
25
|
-
return parsed.theme;
|
|
26
|
-
}
|
|
27
|
-
}
|
|
25
|
+
if (stored) return JSON.parse(stored) as PersistedSettings;
|
|
28
26
|
} catch {
|
|
29
27
|
// ignore
|
|
30
28
|
}
|
|
31
|
-
return
|
|
29
|
+
return {};
|
|
32
30
|
}
|
|
33
31
|
|
|
34
|
-
function
|
|
35
|
-
|
|
32
|
+
function persistSettings(update: Partial<PersistedSettings>) {
|
|
33
|
+
const current = loadPersistedSettings();
|
|
34
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...current, ...update }));
|
|
36
35
|
}
|
|
37
36
|
|
|
38
37
|
/** Apply the resolved theme class to <html> */
|
|
@@ -57,17 +56,26 @@ export function applyThemeClass(theme: Theme) {
|
|
|
57
56
|
}
|
|
58
57
|
}
|
|
59
58
|
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
const _initial = loadPersistedSettings();
|
|
60
|
+
|
|
61
|
+
export const useSettingsStore = create<SettingsState>((set, get) => ({
|
|
62
|
+
theme: (_initial.theme === "light" || _initial.theme === "dark" || _initial.theme === "system") ? _initial.theme : "dark",
|
|
63
|
+
sidebarCollapsed: _initial.sidebarCollapsed ?? false,
|
|
62
64
|
deviceName: null,
|
|
63
65
|
version: null,
|
|
64
66
|
|
|
65
67
|
setTheme: (theme) => {
|
|
66
|
-
|
|
68
|
+
persistSettings({ theme });
|
|
67
69
|
applyThemeClass(theme);
|
|
68
70
|
set({ theme });
|
|
69
71
|
},
|
|
70
72
|
|
|
73
|
+
toggleSidebar: () => {
|
|
74
|
+
const next = !get().sidebarCollapsed;
|
|
75
|
+
persistSettings({ sidebarCollapsed: next });
|
|
76
|
+
set({ sidebarCollapsed: next });
|
|
77
|
+
},
|
|
78
|
+
|
|
71
79
|
fetchServerInfo: async () => {
|
|
72
80
|
try {
|
|
73
81
|
const res = await fetch("/api/info");
|