@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.
Files changed (53) hide show
  1. package/bun.lock +30 -3
  2. package/dist/ppm +0 -0
  3. package/dist/web/assets/{button-CQ5h5gxS.js → button-CvHWF07y.js} +1 -1
  4. package/dist/web/assets/{chat-tab-Cpj7_-mS.js → chat-tab-Cbc-uzKV.js} +4 -4
  5. package/dist/web/assets/{code-editor-BauDrXcB.js → code-editor-B9e5P-DN.js} +1 -1
  6. package/dist/web/assets/{dialog-CCBmXo6-.js → dialog-Cn5zGuid.js} +1 -1
  7. package/dist/web/assets/diff-viewer-B0iMQ1Qf.js +4 -0
  8. package/dist/web/assets/git-graph-D3ls9-HA.js +1 -0
  9. package/dist/web/assets/{git-status-panel-B_iCL1Ge.js → git-status-panel-UB0AyOdX.js} +1 -1
  10. package/dist/web/assets/index-BdUoflYx.css +2 -0
  11. package/dist/web/assets/index-DLIV9ojh.js +17 -0
  12. package/dist/web/assets/{project-list-7ReggIMy.js → project-list-D6oBUMd8.js} +1 -1
  13. package/dist/web/assets/settings-tab-DmTDAK9n.js +1 -0
  14. package/dist/web/assets/{terminal-tab-Cg4Pm_3X.js → terminal-tab-DlRo-KzS.js} +1 -1
  15. package/dist/web/index.html +7 -8
  16. package/dist/web/sw.js +1 -1
  17. package/package.json +3 -2
  18. package/src/providers/claude-agent-sdk.ts +1 -2
  19. package/src/server/index.ts +2 -0
  20. package/src/server/routes/push.ts +54 -0
  21. package/src/server/ws/chat.ts +211 -87
  22. package/src/services/push-notification.service.ts +118 -0
  23. package/src/types/config.ts +7 -0
  24. package/src/web/app.tsx +4 -11
  25. package/src/web/components/layout/draggable-tab.tsx +58 -0
  26. package/src/web/components/layout/editor-panel.tsx +64 -0
  27. package/src/web/components/layout/mobile-nav.tsx +99 -55
  28. package/src/web/components/layout/panel-layout.tsx +71 -0
  29. package/src/web/components/layout/sidebar.tsx +29 -6
  30. package/src/web/components/layout/split-drop-overlay.tsx +111 -0
  31. package/src/web/components/layout/tab-bar.tsx +60 -68
  32. package/src/web/components/settings/settings-tab.tsx +45 -1
  33. package/src/web/hooks/use-chat.ts +31 -2
  34. package/src/web/hooks/use-global-keybindings.ts +8 -0
  35. package/src/web/hooks/use-push-notification.ts +96 -0
  36. package/src/web/hooks/use-tab-drag.ts +109 -0
  37. package/src/web/stores/panel-store.ts +383 -0
  38. package/src/web/stores/panel-utils.ts +116 -0
  39. package/src/web/stores/settings-store.ts +25 -17
  40. package/src/web/stores/tab-store.ts +32 -152
  41. package/src/web/sw.ts +52 -0
  42. package/vite.config.ts +4 -11
  43. package/dist/web/assets/diff-viewer-CGIQRv_l.js +0 -4
  44. package/dist/web/assets/dist-CYANqO1g.js +0 -1
  45. package/dist/web/assets/git-graph-D4IUX9-7.js +0 -1
  46. package/dist/web/assets/index-DOHQ7GlD.js +0 -12
  47. package/dist/web/assets/index-Jhl6F2vS.css +0 -2
  48. package/dist/web/assets/settings-tab-Ceuow24i.js +0 -1
  49. package/dist/web/workbox-3e722498.js +0 -1
  50. /package/dist/web/assets/{api-client-tgjN9Mx8.js → api-client-B_eCZViO.js} +0 -0
  51. /package/dist/web/assets/{dist-0XHv8Vwc.js → dist-B6sG2GPc.js} +0 -0
  52. /package/dist/web/assets/{dist-BeHIxUn0.js → dist-CBiGQxfr.js} +0 -0
  53. /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
- function loadPersistedTheme(): Theme {
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 "dark";
29
+ return {};
32
30
  }
33
31
 
34
- function persistTheme(theme: Theme) {
35
- localStorage.setItem(STORAGE_KEY, JSON.stringify({ theme }));
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
- export const useSettingsStore = create<SettingsState>((set) => ({
61
- theme: loadPersistedTheme(),
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
- persistTheme(theme);
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");