experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(groups): add workspace persistence — save and restore window layouts per group

+179 -2
+30
backend/electron/ipc.ts
··· 2163 2163 winOptions.y = parseInt(options.y); 2164 2164 } 2165 2165 2166 + // Off-screen bounds validation: if explicit x/y were provided, verify the 2167 + // center point falls on an active display. If not (e.g. monitor disconnected), 2168 + // drop x/y so the window centers on the current display instead. 2169 + if (winOptions.x !== undefined && winOptions.y !== undefined) { 2170 + const centerX = winOptions.x + Math.round(((winOptions.width as number) || 800) / 2); 2171 + const centerY = winOptions.y + Math.round(((winOptions.height as number) || 600) / 2); 2172 + const displays = screen.getAllDisplays(); 2173 + const onScreen = displays.some(d => { 2174 + const { x: dx, y: dy, width: dw, height: dh } = d.bounds; 2175 + return centerX >= dx && centerX < dx + dw && centerY >= dy && centerY < dy + dh; 2176 + }); 2177 + if (!onScreen) { 2178 + DEBUG && console.log(`[window-open] Dropping off-screen position (${winOptions.x},${winOptions.y}) center=(${centerX},${centerY})`); 2179 + delete winOptions.x; 2180 + delete winOptions.y; 2181 + } 2182 + } 2183 + 2166 2184 // Determine opener window and whether it's a real content parent 2167 2185 // Background/infrastructure windows (background.html, extension-host.html) are 2168 2186 // not real parents — they're just the IPC sender. Only content windows count ··· 4832 4850 cancelled: false, 4833 4851 ...result, 4834 4852 }; 4853 + } catch (error) { 4854 + const message = error instanceof Error ? error.message : String(error); 4855 + return { success: false, error: message }; 4856 + } 4857 + }); 4858 + 4859 + // Save group workspace layouts on demand (called from groups extension) 4860 + ipcMain.handle('save-group-workspaces', async () => { 4861 + try { 4862 + const { saveGroupWorkspaces } = await import('./session.js'); 4863 + saveGroupWorkspaces(); 4864 + return { success: true }; 4835 4865 } catch (error) { 4836 4866 const message = error instanceof Error ? error.message : String(error); 4837 4867 return { success: false, error: message };
+87
backend/electron/session.ts
··· 271 271 `).run('session-metadata', JSON.stringify(metadata), Date.now()); 272 272 273 273 DEBUG && console.log(`[session] Session snapshot saved successfully`); 274 + 275 + // Also save per-group workspace snapshots (for group layout restore) 276 + saveGroupWorkspaces(); 274 277 } catch (error) { 275 278 console.error('[session] Failed to save session snapshot:', error); 276 279 } ··· 747 750 DEBUG && console.log(`[session] Autosave interval changed: ${_autosaveIntervalMinutes} -> ${intervalMinutes}`); 748 751 startAutosaveTimer(intervalMinutes); 749 752 } 753 + } 754 + 755 + /** 756 + * Save per-group workspace snapshots to extension_settings. 757 + * 758 + * Groups windows by their group mode context and writes a layout snapshot 759 + * for each group. Used by openGroup() to restore window positions. 760 + * Synchronous (safe for before-quit handler). 761 + */ 762 + export function saveGroupWorkspaces(): void { 763 + let db; 764 + try { 765 + db = getDb(); 766 + } catch { 767 + return; 768 + } 769 + 770 + const registeredWindows = getAllWindows(); 771 + 772 + // Use BrowserWindow.getAllWindows() order for z-order approximation 773 + const allBrowserWindows = BrowserWindow.getAllWindows(); 774 + const zOrderMap = new Map<number, number>(); 775 + allBrowserWindows.forEach((win, index) => { 776 + zOrderMap.set(win.id, index); 777 + }); 778 + 779 + // Collect windows grouped by groupId 780 + const groupWindows = new Map<string, { groupName: string; windows: Array<{ url: string; bounds: Electron.Rectangle; zOrder: number; focused: boolean }> }>(); 781 + 782 + for (const [id] of registeredWindows) { 783 + const win = BrowserWindow.fromId(id); 784 + if (!win || win.isDestroyed() || !win.isVisible()) continue; 785 + 786 + const rawUrl = win.webContents.getURL(); 787 + if (rawUrl.includes('peek://app/background.html') || rawUrl.includes('peek://app/extension-host.html')) continue; 788 + 789 + // Check if this window is in group mode 790 + let modeEntry; 791 + try { 792 + modeEntry = getContextEntry('mode', id); 793 + } catch { 794 + continue; 795 + } 796 + if (!modeEntry || modeEntry.value !== 'group') continue; 797 + 798 + const metadata = modeEntry.metadata as Record<string, unknown> | null; 799 + const groupId = metadata?.groupId as string | undefined; 800 + const groupName = metadata?.groupName as string | undefined; 801 + if (!groupId) continue; 802 + 803 + const realUrl = extractRealUrl(rawUrl); 804 + const canvasBounds = extractCanvasBounds(rawUrl); 805 + const bounds = canvasBounds || win.getBounds(); 806 + 807 + if (!groupWindows.has(groupId)) { 808 + groupWindows.set(groupId, { groupName: groupName || '', windows: [] }); 809 + } 810 + groupWindows.get(groupId)!.windows.push({ 811 + url: realUrl, 812 + bounds, 813 + zOrder: zOrderMap.get(id) ?? 0, 814 + focused: win.isFocused(), 815 + }); 816 + } 817 + 818 + // Write each group's workspace snapshot 819 + const upsert = db.prepare(` 820 + INSERT OR REPLACE INTO extension_settings (id, extensionId, key, value, updatedAt) 821 + VALUES (?, 'group-workspaces', ?, ?, ?) 822 + `); 823 + 824 + for (const [groupId, group] of groupWindows) { 825 + const snapshot = { 826 + version: 1, 827 + groupId, 828 + groupName: group.groupName, 829 + createdAt: Date.now(), 830 + windows: group.windows, 831 + }; 832 + const key = `workspace:${groupId}`; 833 + upsert.run(`group-workspaces:${key}`, key, JSON.stringify(snapshot), Date.now()); 834 + } 835 + 836 + DEBUG && console.log(`[session] Saved workspace snapshots for ${groupWindows.size} group(s)`); 750 837 } 751 838 752 839 /**
+51 -2
extensions/groups/background.js
··· 231 231 } 232 232 233 233 console.log(`[ext:groups] Saved ${savedCount} URLs to group "${groupName}"`); 234 + 235 + // Persist current window layouts for this group 236 + try { 237 + if (api.session?.saveGroupWorkspaces) { 238 + await api.session.saveGroupWorkspaces(); 239 + debug && console.log('[ext:groups] Group workspace layouts saved'); 240 + } 241 + } catch (err) { 242 + debug && console.log('[ext:groups] Failed to save workspace layouts:', err); 243 + } 244 + 234 245 return { success: true, count: savedCount, total: listResult.windows.length }; 235 246 }; 236 247 ··· 290 301 activeGroupId = tag.id; 291 302 activeGroupName = tag.name; 292 303 304 + // Load saved workspace snapshot for this group (if any) 305 + let savedBoundsMap = null; 306 + let sortedUrlItems = urlItems; 307 + try { 308 + const wsResult = await api.settings.getExtKey('group-workspaces', 'workspace:' + tag.id); 309 + if (wsResult.success && wsResult.data) { 310 + const snapshot = typeof wsResult.data === 'string' ? JSON.parse(wsResult.data) : wsResult.data; 311 + if (snapshot.version === 1 && Array.isArray(snapshot.windows)) { 312 + // Build URL → bounds map 313 + savedBoundsMap = new Map(); 314 + for (const w of snapshot.windows) { 315 + if (w.url && w.bounds) { 316 + savedBoundsMap.set(normalizeUrlForCompare(w.url), { bounds: w.bounds, zOrder: w.zOrder ?? 0 }); 317 + } 318 + } 319 + // Sort items by saved z-order (highest zOrder = opened later = on top) 320 + sortedUrlItems = [...urlItems].sort((a, b) => { 321 + const aZ = savedBoundsMap.get(normalizeUrlForCompare(a._openUrl))?.zOrder ?? 0; 322 + const bZ = savedBoundsMap.get(normalizeUrlForCompare(b._openUrl))?.zOrder ?? 0; 323 + return bZ - aZ; 324 + }); 325 + debug && console.log(`[ext:groups] Loaded workspace snapshot with ${snapshot.windows.length} saved positions`); 326 + } 327 + } 328 + } catch (err) { 329 + debug && console.log('[ext:groups] No saved workspace layout, using defaults:', err); 330 + } 331 + 293 332 // Open windows and set group mode 294 333 const openedWindows = []; 295 - for (const item of urlItems) { 334 + for (const item of sortedUrlItems) { 335 + // Look up saved bounds for this URL 336 + const savedEntry = savedBoundsMap?.get(normalizeUrlForCompare(item._openUrl)); 337 + const boundsOpts = savedEntry?.bounds ? { 338 + x: savedEntry.bounds.x, 339 + y: savedEntry.bounds.y, 340 + width: savedEntry.bounds.width, 341 + height: savedEntry.bounds.height, 342 + } : {}; 343 + 296 344 const result = await api.window.open(item._openUrl, { 297 345 role: 'content', 298 346 trackingSource: 'cmd', ··· 302 350 groupId: tag.id, 303 351 groupName: tag.name, 304 352 color: tag.color 305 - } 353 + }, 354 + ...boundsOpts 306 355 }); 307 356 if (result?.id) { 308 357 openedWindows.push(result.id);
+11
preload.js
··· 1731 1731 } 1732 1732 }; 1733 1733 1734 + // Session API — allows extensions to trigger session-level operations 1735 + api.session = { 1736 + /** 1737 + * Save group workspace layouts (window positions per group) 1738 + * @returns {Promise<{success: boolean, error?: string}>} 1739 + */ 1740 + saveGroupWorkspaces: () => { 1741 + return ipcRenderer.invoke('save-group-workspaces'); 1742 + } 1743 + }; 1744 + 1734 1745 // Net fetch API - proxies HTTP requests through main process to bypass CORS 1735 1746 // Useful for extensions that need to fetch from external APIs 1736 1747 api.net = {