experiments in a post-browser web
10
fork

Configure Feed

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

refactor(groups): collapse into single tile (non-resident)

Fifth consolidation in the series, after websearch (commit 2426191a),
entities (31ae6cdd), lex (74567f3f), tag-actions (682f9d87).

Groups is the one case where resident:true is explicitly wrong.
Root cause of the earlier attempt's test breakage (all 5
tests/desktop/groups-context.spec.ts tests regressed to mode stuck
at default) was a Playwright URL substring-match collision, NOT the
bootstrap pattern. With resident:true, a hidden startup window
loads peek://groups/home.html at boot. The tests open a second
groups window via the legacy peek://ext/groups/home.html URL.
sharedApp.getWindow('groups/home.html', ...) substring-matches
BOTH and returns the hidden one (created first). Clicks hit the
hidden window, setMode wrote to windowId N while the test checked
windowId M, mode looked stuck. Verified via diag during this
session: setMode returned {success:true, windowId:13}, test checked
windowId:35.

Websearch, entities, lex, tag-actions don't hit this because their
test URLs match the resident URL exactly. Groups tests use the
legacy /ext/ prefix — legitimate reason to not make it resident.

Solution: consolidate to one tile WITHOUT resident:true. Groups is
invoked lazily via the 'groups' noun command and Cmd+G shortcut
(registered declaratively in main.ts for CONSOLIDATED_EXTENSION_IDS).
On first invocation the tile launches, an async IIFE at module
top-level runs api.initialize then initBackground (noun registration,
setGroupMode/exitGroupMode/openGroup, settings load/save, shutdown),
then the existing DOMContentLoaded listener fires showGroups for
the UI. No URL collision, no extra setMode writes at boot.

Changes:
- manifest.json: single home tile entry with flat window fields, no
resident flag. Kept the existing pubsub/commands/datastore caps.
- home.js: merged 648 lines of background.js logic into a dedicated
block before const init. DOMContentLoaded hookup and showGroups/
showAddresses entirely untouched.
- background.js and background.html deleted (886 + 37 lines).
- Net -286 lines.

Tests: Group Mode Context 5/5 (was 0/5 before merge), Groups
Navigation 1/1, no regressions.

+653 -937
-37
features/groups/background.html
··· 1 - <!DOCTYPE html> 2 - <html> 3 - <head> 4 - <meta charset="utf-8"> 5 - <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';"> 6 - <title>Groups Extension</title> 7 - </head> 8 - <body> 9 - <script type="module"> 10 - import extension from './background.js'; 11 - 12 - const api = window.app; 13 - const extId = extension.id; 14 - 15 - console.log(`[ext:${extId}] background.html loaded`); 16 - 17 - // ── V2 Tile Runtime ── 18 - // Initialize tile — validates capability token with main process 19 - console.log(`[ext:${extId}] initializing v2 tile`); 20 - await api.initialize(); 21 - 22 - // Initialize extension (registers commands, shortcuts) 23 - if (extension.init) { 24 - console.log(`[ext:${extId}] calling init()`); 25 - await extension.init(); 26 - } 27 - 28 - // Register shutdown handler 29 - api.onShutdown(() => { 30 - console.log(`[ext:${extId}] received shutdown`); 31 - if (extension.uninit) { 32 - extension.uninit(); 33 - } 34 - }); 35 - </script> 36 - </body> 37 - </html>
-886
features/groups/background.js
··· 1 - /** 2 - * Groups Extension Background Script 3 - * 4 - * Tag-based grouping of URLs 5 - * 6 - * Runs in isolated extension process (peek://ext/groups/background.html) 7 - * Uses api.settings for datastore-backed settings storage 8 - */ 9 - 10 - import { id, labels, schemas, storageKeys, defaults } from './config.js'; 11 - import { registerNoun, unregisterNoun } from 'peek://ext/cmd/nouns.js'; 12 - 13 - const api = window.app; 14 - const debug = api.debug; 15 - 16 - console.log('[ext:groups] background', labels.name); 17 - 18 - // Extension content is served from peek://ext/groups/ 19 - const address = 'peek://ext/groups/home.html'; 20 - 21 - // In-memory settings cache (loaded from datastore on init) 22 - let currentSettings = { 23 - prefs: defaults.prefs 24 - }; 25 - 26 - // Track the groups window ID for mode cleanup 27 - let groupsWindowId = null; 28 - 29 - // Track the current active group context 30 - let activeGroupId = null; 31 - let activeGroupName = null; 32 - 33 - // Track suspended (hidden) group windows: groupId -> [windowId, ...] 34 - const suspendedGroups = new Map(); 35 - 36 - /** 37 - * Load settings from datastore 38 - * @returns {Promise<{prefs: object}>} 39 - */ 40 - const loadSettings = async () => { 41 - const result = await api.settings.get('prefs'); 42 - if (!result.error && result.value) { 43 - return { 44 - prefs: result.value || defaults.prefs 45 - }; 46 - } 47 - return { prefs: defaults.prefs }; 48 - }; 49 - 50 - /** 51 - * Save settings to datastore 52 - * @param {object} settings - Settings object with prefs 53 - */ 54 - const saveSettings = async (settings) => { 55 - const result = await api.settings.set('prefs', settings.prefs); 56 - if (result.error) { 57 - console.error('[ext:groups] Failed to save settings:', result.error); 58 - } 59 - }; 60 - 61 - let isOpeningGroups = false; 62 - const openGroupsWindow = async () => { 63 - if (isOpeningGroups) return; 64 - isOpeningGroups = true; 65 - try { 66 - const height = 600; 67 - const width = 800; 68 - 69 - const params = { 70 - // IZUI role 71 - role: 'workspace', 72 - 73 - key: address, 74 - height, 75 - width, 76 - trackingSource: 'cmd', 77 - trackingSourceId: 'groups' 78 - }; 79 - 80 - const window = await api.window.open(address, params); 81 - debug && console.log('[ext:groups] Groups window opened:', window); 82 - groupsWindowId = window?.id || null; 83 - } catch (error) { 84 - console.error('[ext:groups] Failed to open groups window:', error); 85 - } finally { 86 - isOpeningGroups = false; 87 - } 88 - }; 89 - 90 - /** 91 - * Set group mode for a window via context API 92 - */ 93 - const setGroupMode = async (windowId, groupId, groupName, color = null) => { 94 - if (!api.context) { 95 - debug && console.log('[ext:groups] Context API not available'); 96 - return; 97 - } 98 - 99 - try { 100 - await api.context.setMode('group', { 101 - windowId, 102 - metadata: { 103 - groupId, 104 - groupName, 105 - color 106 - } 107 - }); 108 - debug && console.log(`[ext:groups] Set group mode for window ${windowId}: ${groupName}`); 109 - } catch (err) { 110 - console.error('[ext:groups] Failed to set group mode:', err); 111 - } 112 - }; 113 - 114 - /** 115 - * Exit group mode for all windows in a group 116 - * Resets them back to their content-based mode (page/default) 117 - */ 118 - const exitGroupMode = async (groupId) => { 119 - if (!api.context) { 120 - debug && console.log('[ext:groups] Context API not available'); 121 - return; 122 - } 123 - 124 - try { 125 - // Get all windows in this group 126 - const result = await api.context.getWindowsInSpace(groupId); 127 - if (!result.success || !result.data) return; 128 - 129 - const windowIds = result.data; 130 - debug && console.log(`[ext:groups] Exiting group mode for ${windowIds.length} windows`); 131 - 132 - // Reset each window to page mode (content-based) 133 - for (const windowId of windowIds) { 134 - await api.context.setMode('page', { windowId }); 135 - } 136 - } catch (err) { 137 - console.error('[ext:groups] Failed to exit group mode:', err); 138 - } 139 - }; 140 - 141 - /** 142 - * Resolve the current group from the active context or last focused window. 143 - * Returns { groupId, groupName } or null. 144 - */ 145 - const resolveCurrentGroup = async () => { 146 - if (activeGroupId) { 147 - return { groupId: activeGroupId, groupName: activeGroupName }; 148 - } 149 - 150 - try { 151 - const targetWindowId = await api.window.getFocusedVisibleWindowId(); 152 - if (targetWindowId) { 153 - const modeResult = await api.context.get('mode', targetWindowId); 154 - if (modeResult.success && modeResult.data?.value === 'group' && modeResult.data.metadata?.groupId) { 155 - return { 156 - groupId: modeResult.data.metadata.groupId, 157 - groupName: modeResult.data.metadata.groupName || '' 158 - }; 159 - } 160 - } 161 - } catch (err) { 162 - debug && console.log('[ext:groups] Failed to resolve current group:', err); 163 - } 164 - 165 - return null; 166 - }; 167 - 168 - // ===== Close/Suspend Group ===== 169 - 170 - /** 171 - * Close (suspend) a group — hide all windows in the group context. 172 - * Saves window IDs so they can be restored later. 173 - * If no groupId given, uses the active group from the last focused window. 174 - */ 175 - const closeGroup = async (groupId = null, groupName = null) => { 176 - // Resolve group from active context if not specified 177 - if (!groupId) { 178 - const current = await resolveCurrentGroup(); 179 - if (current) { 180 - groupId = current.groupId; 181 - groupName = current.groupName; 182 - } 183 - } 184 - 185 - if (!groupId) { 186 - return { success: false, error: 'No active group to close' }; 187 - } 188 - 189 - // Save workspace layouts before hiding 190 - try { 191 - if (api.session?.saveSpaceWorkspaces) { 192 - await api.session.saveSpaceWorkspaces(); 193 - } 194 - } catch (err) { 195 - debug && console.log('[ext:groups] Failed to save workspace before close:', err); 196 - } 197 - 198 - // Get all windows in this group 199 - const result = await api.context.getWindowsInSpace(groupId); 200 - if (!result.success || !result.data || result.data.length === 0) { 201 - return { success: false, error: 'No windows found in group' }; 202 - } 203 - 204 - const windowIds = result.data; 205 - const hiddenIds = []; 206 - 207 - // Hide each window 208 - for (const windowId of windowIds) { 209 - try { 210 - await api.window.hide(windowId); 211 - hiddenIds.push(windowId); 212 - } catch (err) { 213 - debug && console.log(`[ext:groups] Failed to hide window ${windowId}:`, err); 214 - } 215 - } 216 - 217 - // Track suspended windows for restore 218 - suspendedGroups.set(groupId, hiddenIds); 219 - 220 - // Clear active group tracking if this was the active group 221 - if (activeGroupId === groupId) { 222 - activeGroupId = null; 223 - activeGroupName = null; 224 - } 225 - 226 - console.log(`[ext:groups] Closed group "${groupName || groupId}": hid ${hiddenIds.length} window(s)`); 227 - return { success: true, count: hiddenIds.length, groupId, groupName }; 228 - }; 229 - 230 - /** 231 - * Restore a suspended group — show all hidden windows. 232 - * Falls back to opening the group fresh if windows were destroyed. 233 - */ 234 - const restoreGroup = async (groupId, groupName = null) => { 235 - const hiddenIds = suspendedGroups.get(groupId); 236 - 237 - if (!hiddenIds || hiddenIds.length === 0) { 238 - // No suspended windows — open the group fresh 239 - if (groupName) { 240 - return openGroup(groupName); 241 - } 242 - return { success: false, error: 'Group not suspended and no name to open' }; 243 - } 244 - 245 - let restoredCount = 0; 246 - const failedIds = []; 247 - 248 - for (const windowId of hiddenIds) { 249 - try { 250 - // Check if window still exists 251 - const exists = await api.window.exists(windowId); 252 - if (exists?.exists) { 253 - await api.window.show(windowId); 254 - restoredCount++; 255 - } else { 256 - failedIds.push(windowId); 257 - } 258 - } catch (err) { 259 - debug && console.log(`[ext:groups] Failed to show window ${windowId}:`, err); 260 - failedIds.push(windowId); 261 - } 262 - } 263 - 264 - // Clean up tracking 265 - suspendedGroups.delete(groupId); 266 - 267 - // If some windows were destroyed, re-open the group to fill in gaps 268 - if (failedIds.length > 0 && groupName) { 269 - console.log(`[ext:groups] ${failedIds.length} windows destroyed while suspended, re-opening group`); 270 - return openGroup(groupName); 271 - } 272 - 273 - // Update active group 274 - activeGroupId = groupId; 275 - activeGroupName = groupName; 276 - 277 - console.log(`[ext:groups] Restored group "${groupName || groupId}": showed ${restoredCount} window(s)`); 278 - return { success: true, count: restoredCount }; 279 - }; 280 - 281 - // ===== Switch Group ===== 282 - 283 - /** 284 - * Switch from the current group to a target group. 285 - * Closes (suspends) the current group, then opens (or restores) the target. 286 - */ 287 - const switchGroup = async (targetGroupName) => { 288 - if (!targetGroupName) { 289 - return { success: false, error: 'Usage: switch group <name>' }; 290 - } 291 - 292 - // Resolve target group 293 - const tagsResult = await api.datastore.getTagsByFrecency(); 294 - if (!tagsResult.success) { 295 - return { success: false, error: 'Failed to get tags' }; 296 - } 297 - 298 - const targetTag = tagsResult.data.find(t => t.name.toLowerCase() === targetGroupName.toLowerCase()); 299 - if (!targetTag) { 300 - return { success: false, error: `Group "${targetGroupName}" not found` }; 301 - } 302 - 303 - // Close current group (if any) 304 - const current = await resolveCurrentGroup(); 305 - if (current && current.groupId !== targetTag.id) { 306 - await closeGroup(current.groupId, current.groupName); 307 - } 308 - 309 - // Restore or open target group 310 - const result = await restoreGroup(targetTag.id, targetTag.name); 311 - console.log(`[ext:groups] Switched to group "${targetGroupName}"`); 312 - return result; 313 - }; 314 - 315 - // ===== Pin Items ===== 316 - 317 - /** 318 - * Get pinned item IDs for a group. 319 - * Stored in feature_settings as 'pins:<groupId>'. 320 - */ 321 - const getPinnedItems = async (groupId) => { 322 - try { 323 - const result = await api.settings.get(`pins:${groupId}`); 324 - if (!result.error && result.value) { 325 - return Array.isArray(result.value) ? result.value : []; 326 - } 327 - } catch (err) { 328 - debug && console.log('[ext:groups] Failed to load pins:', err); 329 - } 330 - return []; 331 - }; 332 - 333 - /** 334 - * Save pinned item IDs for a group. 335 - */ 336 - const savePinnedItems = async (groupId, pinnedItemIds) => { 337 - try { 338 - await api.settings.set(`pins:${groupId}`, pinnedItemIds); 339 - } catch (err) { 340 - console.error('[ext:groups] Failed to save pins:', err); 341 - } 342 - }; 343 - 344 - /** 345 - * Pin an item (URL) in a group. The item must already be tagged with the group. 346 - * If no groupId is specified, uses the active group context. 347 - */ 348 - const pinItem = async (url, groupId = null) => { 349 - // Resolve group from context 350 - if (!groupId) { 351 - const current = await resolveCurrentGroup(); 352 - if (current) { 353 - groupId = current.groupId; 354 - } 355 - } 356 - 357 - if (!groupId) { 358 - return { success: false, error: 'No active group context — open a group first' }; 359 - } 360 - 361 - if (!url) { 362 - return { success: false, error: 'Usage: pin <url>' }; 363 - } 364 - 365 - // Find the item for this URL 366 - const item = await getOrCreateUrlItem(url); 367 - if (!item) { 368 - return { success: false, error: 'Failed to find or create item for URL' }; 369 - } 370 - 371 - // Load current pins and add 372 - const pins = await getPinnedItems(groupId); 373 - if (!pins.includes(item.id)) { 374 - pins.push(item.id); 375 - await savePinnedItems(groupId, pins); 376 - } 377 - 378 - // Publish event for UI reactivity 379 - api.pubsub.publish('group:pin-changed', { groupId, itemId: item.id, pinned: true }, api.scopes.GLOBAL); 380 - 381 - console.log(`[ext:groups] Pinned item "${url}" in group ${groupId}`); 382 - return { success: true, itemId: item.id, groupId }; 383 - }; 384 - 385 - /** 386 - * Unpin an item from a group. 387 - */ 388 - const unpinItem = async (url, groupId = null) => { 389 - if (!groupId) { 390 - const current = await resolveCurrentGroup(); 391 - if (current) { 392 - groupId = current.groupId; 393 - } 394 - } 395 - 396 - if (!groupId) { 397 - return { success: false, error: 'No active group context' }; 398 - } 399 - 400 - if (!url) { 401 - return { success: false, error: 'Usage: unpin <url>' }; 402 - } 403 - 404 - // Find the item 405 - const item = await getOrCreateUrlItem(url); 406 - if (!item) { 407 - return { success: false, error: 'Item not found' }; 408 - } 409 - 410 - // Remove from pins 411 - const pins = await getPinnedItems(groupId); 412 - const idx = pins.indexOf(item.id); 413 - if (idx !== -1) { 414 - pins.splice(idx, 1); 415 - await savePinnedItems(groupId, pins); 416 - } 417 - 418 - // Publish event for UI reactivity 419 - api.pubsub.publish('group:pin-changed', { groupId, itemId: item.id, pinned: false }, api.scopes.GLOBAL); 420 - 421 - console.log(`[ext:groups] Unpinned item "${url}" from group ${groupId}`); 422 - return { success: true, itemId: item.id, groupId }; 423 - }; 424 - 425 - // ===== Command helpers ===== 426 - 427 - /** 428 - * Helper to get or create a URL item 429 - */ 430 - const normalizeUrlForCompare = (url) => { 431 - try { 432 - const u = new URL(url); 433 - // Lowercase hostname, remove default ports, remove trailing slash for non-root paths 434 - let normalized = u.protocol + '//' + u.hostname.toLowerCase(); 435 - if (u.port && u.port !== '80' && u.port !== '443') normalized += ':' + u.port; 436 - let path = u.pathname; 437 - if (path.length > 1 && path.endsWith('/')) path = path.slice(0, -1); 438 - normalized += path + u.search + u.hash; 439 - return normalized; 440 - } catch { 441 - return url; 442 - } 443 - }; 444 - 445 - const getOrCreateUrlItem = async (url, title = '') => { 446 - // Search narrows to matching URLs, then normalize-compare for exact match 447 - const result = await api.datastore.queryItems({ type: 'url', search: url, limit: 10 }); 448 - if (!result.success) return null; 449 - 450 - const normalizedUrl = normalizeUrlForCompare(url); 451 - const existing = result.data.find(item => normalizeUrlForCompare(item.content) === normalizedUrl); 452 - if (existing) return existing; 453 - 454 - const addResult = await api.datastore.addItem('url', { 455 - content: url, 456 - metadata: JSON.stringify({ title }) 457 - }); 458 - if (!addResult.success) return null; 459 - 460 - return { id: addResult.data.id, content: url }; 461 - }; 462 - 463 - /** 464 - * Get all tags (groups) sorted by frecency 465 - */ 466 - const getAllGroups = async () => { 467 - const result = await api.datastore.getTagsByFrecency(); 468 - if (!result.success) return []; 469 - return result.data; 470 - }; 471 - 472 - /** 473 - * Save current windows to a group (tag) 474 - */ 475 - const saveToGroup = async (groupName) => { 476 - console.log('[ext:groups] Saving to group:', groupName); 477 - 478 - const tagResult = await api.datastore.getOrCreateTag(groupName); 479 - if (!tagResult.success) { 480 - console.error('[ext:groups] Failed to get/create tag:', tagResult.error); 481 - return { success: false, error: tagResult.error }; 482 - } 483 - 484 - const tag = tagResult.data.tag; 485 - const tagId = tag.id; 486 - 487 - // Auto-promote: ensure the tag has isGroup: true in metadata 488 - try { 489 - let meta = {}; 490 - if (tag.metadata) { 491 - meta = typeof tag.metadata === 'object' ? tag.metadata : JSON.parse(tag.metadata); 492 - } 493 - if (!meta.isGroup) { 494 - meta.isGroup = true; 495 - await api.datastore.setRow('tags', tagId, { ...tag, metadata: JSON.stringify(meta) }); 496 - debug && console.log('[ext:groups] Auto-promoted tag to group:', groupName); 497 - } 498 - } catch (err) { 499 - console.error('[ext:groups] Failed to auto-promote tag:', err); 500 - } 501 - 502 - const listResult = await api.window.list({ includeInternal: false }); 503 - if (!listResult.success || listResult.windows.length === 0) { 504 - console.log('[ext:groups] No windows to save'); 505 - return { success: false, error: 'No windows to save' }; 506 - } 507 - 508 - let savedCount = 0; 509 - 510 - for (const win of listResult.windows) { 511 - const item = await getOrCreateUrlItem(win.url, win.title); 512 - if (item) { 513 - const linkResult = await api.datastore.tagItem(item.id, tagId); 514 - if (linkResult.success && !linkResult.alreadyExists) { 515 - savedCount++; 516 - } 517 - } 518 - } 519 - 520 - console.log(`[ext:groups] Saved ${savedCount} URLs to group "${groupName}"`); 521 - 522 - // Persist current window layouts for this group 523 - try { 524 - if (api.session?.saveSpaceWorkspaces) { 525 - await api.session.saveSpaceWorkspaces(); 526 - debug && console.log('[ext:groups] Group workspace layouts saved'); 527 - } 528 - } catch (err) { 529 - debug && console.log('[ext:groups] Failed to save workspace layouts:', err); 530 - } 531 - 532 - return { success: true, count: savedCount, total: listResult.windows.length }; 533 - }; 534 - 535 - /** 536 - * Open all URLs in a group (tag) 537 - */ 538 - const openGroup = async (groupName) => { 539 - console.log('[ext:groups] Opening group:', groupName); 540 - 541 - const tagsResult = await api.datastore.getTagsByFrecency(); 542 - if (!tagsResult.success) { 543 - return { success: false, error: 'Failed to get tags' }; 544 - } 545 - 546 - const tag = tagsResult.data.find(t => t.name.toLowerCase() === groupName.toLowerCase()); 547 - if (!tag) { 548 - console.log('[ext:groups] Group not found:', groupName); 549 - return { success: false, error: 'Group not found' }; 550 - } 551 - 552 - const itemsResult = await api.datastore.getItemsByTag(tag.id); 553 - if (!itemsResult.success) { 554 - console.log('[ext:groups] Failed to get items for group:', groupName); 555 - return { success: false, error: 'Failed to get group items' }; 556 - } 557 - 558 - // Filter to items with URLs (explicit URL items + text items containing URLs) 559 - const allUrlItems = itemsResult.data 560 - .map(item => { 561 - if (item.type === 'url') return { ...item, _openUrl: item.content }; 562 - if (item.type === 'text' && item.content) { 563 - // Check if text note contains a URL 564 - const urlMatch = item.content.trim().match(/^https?:\/\/\S+/) || item.content.match(/https?:\/\/[^\s<>"')\]]+/i); 565 - if (urlMatch) { 566 - try { new URL(urlMatch[0]); return { ...item, _openUrl: urlMatch[0] }; } catch (e) {} 567 - } 568 - } 569 - return null; 570 - }) 571 - .filter(Boolean); 572 - 573 - // Deduplicate by normalized URL to avoid opening the same page twice 574 - const seenUrls = new Set(); 575 - const urlItems = allUrlItems.filter(item => { 576 - const normalized = normalizeUrlForCompare(item._openUrl); 577 - if (seenUrls.has(normalized)) return false; 578 - seenUrls.add(normalized); 579 - return true; 580 - }); 581 - 582 - if (urlItems.length === 0) { 583 - console.log('[ext:groups] No URLs in group:', groupName); 584 - return { success: false, error: 'Group is empty' }; 585 - } 586 - 587 - // Load pinned items — these always open even if not in workspace snapshot 588 - const pinnedItemIds = await getPinnedItems(tag.id); 589 - const pinnedSet = new Set(pinnedItemIds); 590 - 591 - // Track active group for mode inheritance 592 - activeGroupId = tag.id; 593 - activeGroupName = tag.name; 594 - 595 - // Load saved workspace snapshot for this group (if any) 596 - let savedBoundsMap = null; 597 - let sortedUrlItems = urlItems; 598 - try { 599 - // Cross-extension read of spaces' workspace snapshot. 600 - // Strict tile preload doesn't expose getExtKey — guard for v2 strict. 601 - const wsResult = api.settings.getExtKey 602 - ? await api.settings.getExtKey('spaces', 'workspace:' + tag.id) 603 - : { success: false }; 604 - if (wsResult.success && wsResult.data) { 605 - const snapshot = typeof wsResult.data === 'string' ? JSON.parse(wsResult.data) : wsResult.data; 606 - if (snapshot.version === 1 && Array.isArray(snapshot.windows)) { 607 - // Build URL -> bounds map 608 - savedBoundsMap = new Map(); 609 - for (const w of snapshot.windows) { 610 - if (w.url && w.bounds) { 611 - savedBoundsMap.set(normalizeUrlForCompare(w.url), { bounds: w.bounds, zOrder: w.zOrder ?? 0 }); 612 - } 613 - } 614 - // Sort items: pinned first, then by saved z-order (highest zOrder = opened later = on top) 615 - sortedUrlItems = [...urlItems].sort((a, b) => { 616 - const aPinned = pinnedSet.has(a.id) ? 1 : 0; 617 - const bPinned = pinnedSet.has(b.id) ? 1 : 0; 618 - if (aPinned !== bPinned) return bPinned - aPinned; // pinned first 619 - const aZ = savedBoundsMap.get(normalizeUrlForCompare(a._openUrl))?.zOrder ?? 0; 620 - const bZ = savedBoundsMap.get(normalizeUrlForCompare(b._openUrl))?.zOrder ?? 0; 621 - return bZ - aZ; 622 - }); 623 - debug && console.log(`[ext:groups] Loaded workspace snapshot with ${snapshot.windows.length} saved positions`); 624 - } 625 - } 626 - } catch (err) { 627 - debug && console.log('[ext:groups] No saved workspace layout, using defaults:', err); 628 - } 629 - 630 - // Open windows and set group mode 631 - const openedWindows = []; 632 - for (const item of sortedUrlItems) { 633 - // Look up saved bounds for this URL 634 - const savedEntry = savedBoundsMap?.get(normalizeUrlForCompare(item._openUrl)); 635 - const boundsOpts = savedEntry?.bounds ? { 636 - x: savedEntry.bounds.x, 637 - y: savedEntry.bounds.y, 638 - width: savedEntry.bounds.width, 639 - height: savedEntry.bounds.height, 640 - } : {}; 641 - 642 - const result = await api.window.open(item._openUrl, { 643 - role: 'content', 644 - trackingSource: 'cmd', 645 - trackingSourceId: `group:${groupName}`, 646 - // Pass group context for mode inheritance 647 - groupMode: { 648 - groupId: tag.id, 649 - groupName: tag.name, 650 - color: tag.color 651 - }, 652 - ...boundsOpts 653 - }); 654 - if (result?.id) { 655 - openedWindows.push(result.id); 656 - // Set group mode for the opened window 657 - await setGroupMode(result.id, tag.id, tag.name, tag.color); 658 - } 659 - } 660 - 661 - console.log(`[ext:groups] Opened ${urlItems.length} windows from group "${groupName}"`); 662 - return { success: true, count: urlItems.length, windowIds: openedWindows }; 663 - }; 664 - 665 - // ===== Registration ===== 666 - 667 - let registeredShortcut = null; 668 - const LOCAL_SHORTCUT = 'CommandOrControl+G'; 669 - 670 - const initShortcut = (shortcut) => { 671 - api.shortcuts.register(shortcut, () => { 672 - openGroupsWindow(); 673 - }, { global: true }); 674 - registeredShortcut = shortcut; 675 - 676 - // Local shortcut (Cmd+G) — works when a Peek window is focused 677 - api.shortcuts.register(LOCAL_SHORTCUT, () => { 678 - openGroupsWindow(); 679 - }); 680 - }; 681 - 682 - const initCommands = () => { 683 - registerNoun({ 684 - name: 'groups', 685 - singular: 'group', 686 - description: 'Saved tab groups', 687 - 688 - query: async ({ search }) => { 689 - // Group-scoped search: when in group mode, filter items by the active group tag 690 - const current = await resolveCurrentGroup(); 691 - 692 - const groups = await getAllGroups(); 693 - const withCounts = await Promise.all(groups.map(async g => { 694 - const result = await api.datastore.getItemsByTag(g.id); 695 - const count = result.success ? result.data.filter(i => i.type === 'url').length : 0; 696 - return { id: g.id, name: g.name, color: g.color, count }; 697 - })); 698 - let filtered = withCounts.filter(g => g.count > 0); 699 - if (search) { 700 - const s = search.toLowerCase(); 701 - filtered = filtered.filter(g => g.name.toLowerCase().includes(s)); 702 - } 703 - 704 - // If in group mode, highlight the active group by moving it to the top 705 - if (current) { 706 - const activeIdx = filtered.findIndex(g => g.id === current.groupId); 707 - if (activeIdx > 0) { 708 - const [active] = filtered.splice(activeIdx, 1); 709 - active.name = `${active.name} (active)`; 710 - filtered.unshift(active); 711 - } 712 - } 713 - 714 - if (filtered.length === 0) { 715 - return { output: 'No groups found.', mimeType: 'text/plain' }; 716 - } 717 - 718 - // Add search scope indicator when in group mode 719 - const title = current 720 - ? `Groups (${filtered.length}) [scope: ${current.groupName}]` 721 - : `Groups (${filtered.length})`; 722 - 723 - return { 724 - success: true, 725 - output: { 726 - data: filtered, 727 - mimeType: 'application/json', 728 - title 729 - } 730 - }; 731 - }, 732 - 733 - browse: async () => { openGroupsWindow(); }, 734 - 735 - open: async (ctx) => { 736 - if (ctx.search) await openGroup(ctx.search.trim()); 737 - }, 738 - 739 - create: async ({ search }) => { 740 - if (!search) return { success: false, error: 'Usage: new group <name>' }; 741 - return await saveToGroup(search.trim()); 742 - }, 743 - 744 - produces: 'application/json' 745 - }); 746 - 747 - console.log('[ext:groups] Noun registered: groups'); 748 - 749 - // ===== Standalone commands (pin, unpin) ===== 750 - // Workspace commands (close, switch, restore) moved to spaces feature. 751 - 752 - // "pin <url>" — mark a URL as pinned in the current group 753 - api.pubsub.subscribe('cmd:execute:pin', async (msg) => { 754 - const result = await pinItem(msg.search?.trim()); 755 - if (msg.expectResult && msg.resultTopic) { 756 - api.pubsub.publish(msg.resultTopic, result || { success: true }, api.scopes.GLOBAL); 757 - } 758 - }, api.scopes.GLOBAL); 759 - api.pubsub.publish('cmd:register', { 760 - name: 'pin', 761 - description: 'Pin a URL in the current group (always opens with group)', 762 - source: 'groups', 763 - scope: 'global', 764 - accepts: [], 765 - produces: [], 766 - params: [{ name: 'url', type: 'string', required: true, description: 'URL to pin' }] 767 - }, api.scopes.GLOBAL); 768 - 769 - // "unpin <url>" — remove pin from a URL in the current group 770 - api.pubsub.subscribe('cmd:execute:unpin', async (msg) => { 771 - const result = await unpinItem(msg.search?.trim()); 772 - if (msg.expectResult && msg.resultTopic) { 773 - api.pubsub.publish(msg.resultTopic, result || { success: true }, api.scopes.GLOBAL); 774 - } 775 - }, api.scopes.GLOBAL); 776 - api.pubsub.publish('cmd:register', { 777 - name: 'unpin', 778 - description: 'Unpin a URL from the current group', 779 - source: 'groups', 780 - scope: 'global', 781 - accepts: [], 782 - produces: [], 783 - params: [{ name: 'url', type: 'string', required: true, description: 'URL to unpin' }] 784 - }, api.scopes.GLOBAL); 785 - 786 - console.log('[ext:groups] Standalone commands registered: pin, unpin'); 787 - }; 788 - 789 - const uninitCommands = () => { 790 - unregisterNoun('groups'); 791 - // Unregister standalone commands 792 - for (const name of ['pin', 'unpin']) { 793 - api.pubsub.publish('cmd:unregister', { name }, api.scopes.GLOBAL); 794 - } 795 - console.log('[ext:groups] Noun and commands unregistered: groups'); 796 - }; 797 - 798 - const init = async () => { 799 - console.log('[ext:groups] init'); 800 - 801 - // Load settings from datastore 802 - currentSettings = await loadSettings(); 803 - 804 - initShortcut(currentSettings.prefs.shortcutKey); 805 - 806 - // Register commands (cmd loads first with its subscribers ready via 100ms head start) 807 - initCommands(); 808 - 809 - // Listen for window close events to clean up groups window tracking 810 - // NOTE: We do NOT exit group mode when the groups panel closes. 811 - // Group mode persists on member windows. It is only cleared when: 812 - // - The user navigates back to the groups list (handled in home.js showGroups) 813 - // - The user explicitly changes mode 814 - api.pubsub.subscribe('window:closed', async (msg) => { 815 - const closedWindowId = msg?.id; 816 - 817 - if (closedWindowId === groupsWindowId) { 818 - debug && console.log('[ext:groups] Groups window closed, keeping group mode on member windows'); 819 - groupsWindowId = null; 820 - } 821 - }, api.scopes.GLOBAL); 822 - 823 - // Listen for settings changes to hot-reload (GLOBAL scope for cross-process) 824 - api.pubsub.subscribe('groups:settings-changed', async () => { 825 - console.log('[ext:groups] settings changed, reinitializing'); 826 - uninit(); 827 - currentSettings = await loadSettings(); 828 - initShortcut(currentSettings.prefs.shortcutKey); 829 - initCommands(); 830 - }, api.scopes.GLOBAL); 831 - 832 - // Listen for settings updates from Settings UI 833 - // Settings UI sends proposed changes, we validate and save 834 - api.pubsub.subscribe('groups:settings-update', async (msg) => { 835 - console.log('[ext:groups] settings-update received:', msg); 836 - 837 - try { 838 - // Apply the update based on what was sent 839 - if (msg.data) { 840 - // Full data object sent 841 - currentSettings = { 842 - prefs: msg.data.prefs || currentSettings.prefs 843 - }; 844 - } else if (msg.key === 'prefs' && msg.path) { 845 - // Single pref field update 846 - const field = msg.path.split('.')[1]; 847 - if (field) { 848 - currentSettings.prefs = { ...currentSettings.prefs, [field]: msg.value }; 849 - } 850 - } 851 - 852 - // Save to datastore 853 - await saveSettings(currentSettings); 854 - 855 - // Reinitialize with new settings 856 - uninit(); 857 - initShortcut(currentSettings.prefs.shortcutKey); 858 - initCommands(); 859 - 860 - // Confirm change back to Settings UI 861 - api.pubsub.publish('groups:settings-changed', currentSettings, api.scopes.GLOBAL); 862 - } catch (err) { 863 - console.error('[ext:groups] settings-update error:', err); 864 - } 865 - }, api.scopes.GLOBAL); 866 - }; 867 - 868 - const uninit = () => { 869 - console.log('[ext:groups] uninit'); 870 - if (registeredShortcut) { 871 - api.shortcuts.unregister(registeredShortcut, { global: true }); 872 - registeredShortcut = null; 873 - } 874 - api.shortcuts.unregister(LOCAL_SHORTCUT); 875 - uninitCommands(); 876 - }; 877 - 878 - export default { 879 - defaults, 880 - id, 881 - init, 882 - uninit, 883 - labels, 884 - schemas, 885 - storageKeys 886 - };
+648
features/groups/home.js
··· 18 18 createAffordanceElements, createActionRulesCache 19 19 } from 'peek://app/lib/tag-action-affordances.js'; 20 20 import { createSearchResultCard } from 'peek://app/lib/search-result-card.js'; 21 + import { labels, defaults } from './config.js'; 22 + import { registerNoun, unregisterNoun } from 'peek://ext/cmd/nouns.js'; 21 23 22 24 const api = window.app; 23 25 const debug = api.debug; ··· 348 350 } 349 351 }); 350 352 }; 353 + 354 + // ============================================================================ 355 + // Background logic (inlined from background.js) 356 + // ============================================================================ 357 + // Commands, shortcuts, bulk-open (openGroup), pins, close/switch/restore, 358 + // settings load/save, and shutdown-related pubsub subscriptions. 359 + // Runs once at module load (see bootstrap IIFE at bottom), NOT inside the 360 + // DOMContentLoaded init() — keeps UI init path clean. 361 + 362 + console.log('[ext:groups] background', labels.name); 363 + 364 + // Extension content is served from peek://ext/groups/ 365 + const bgAddress = 'peek://ext/groups/home.html'; 366 + 367 + // In-memory settings cache (loaded from datastore during bg init) 368 + let currentSettings = { 369 + prefs: defaults.prefs 370 + }; 371 + 372 + // Track the groups window ID for mode cleanup (from bg-initiated opens) 373 + let groupsWindowId = null; 374 + 375 + // Track the current active group context (bulk-open path) 376 + let activeGroupId = null; 377 + let activeGroupName = null; 378 + 379 + // Track suspended (hidden) group windows: groupId -> [windowId, ...] 380 + const suspendedGroups = new Map(); 381 + 382 + /** Load settings from datastore */ 383 + const loadSettings = async () => { 384 + const result = await api.settings.get('prefs'); 385 + if (!result.error && result.value) { 386 + return { prefs: result.value || defaults.prefs }; 387 + } 388 + return { prefs: defaults.prefs }; 389 + }; 390 + 391 + /** Save settings to datastore */ 392 + const saveSettings = async (settings) => { 393 + const result = await api.settings.set('prefs', settings.prefs); 394 + if (result.error) { 395 + console.error('[ext:groups] Failed to save settings:', result.error); 396 + } 397 + }; 398 + 399 + let isOpeningGroups = false; 400 + const openGroupsWindow = async () => { 401 + if (isOpeningGroups) return; 402 + isOpeningGroups = true; 403 + try { 404 + const params = { 405 + role: 'workspace', 406 + key: bgAddress, 407 + height: 600, 408 + width: 800, 409 + trackingSource: 'cmd', 410 + trackingSourceId: 'groups' 411 + }; 412 + const window = await api.window.open(bgAddress, params); 413 + debug && console.log('[ext:groups] Groups window opened:', window); 414 + groupsWindowId = window?.id || null; 415 + } catch (error) { 416 + console.error('[ext:groups] Failed to open groups window:', error); 417 + } finally { 418 + isOpeningGroups = false; 419 + } 420 + }; 421 + 422 + /** Set group mode for a window via context API */ 423 + const setGroupMode = async (windowId, groupId, groupName, color = null) => { 424 + if (!api.context) return; 425 + try { 426 + await api.context.setMode('group', { 427 + windowId, 428 + metadata: { groupId, groupName, color } 429 + }); 430 + debug && console.log(`[ext:groups] Set group mode for window ${windowId}: ${groupName}`); 431 + } catch (err) { 432 + console.error('[ext:groups] Failed to set group mode:', err); 433 + } 434 + }; 435 + 436 + /** Exit group mode for all windows in a group */ 437 + const exitGroupMode = async (groupId) => { 438 + if (!api.context) return; 439 + try { 440 + const result = await api.context.getWindowsInSpace(groupId); 441 + if (!result.success || !result.data) return; 442 + const windowIds = result.data; 443 + debug && console.log(`[ext:groups] Exiting group mode for ${windowIds.length} windows`); 444 + for (const windowId of windowIds) { 445 + await api.context.setMode('page', { windowId }); 446 + } 447 + } catch (err) { 448 + console.error('[ext:groups] Failed to exit group mode:', err); 449 + } 450 + }; 451 + 452 + /** Resolve current group from active context or last focused window */ 453 + const resolveCurrentGroup = async () => { 454 + if (activeGroupId) { 455 + return { groupId: activeGroupId, groupName: activeGroupName }; 456 + } 457 + try { 458 + const targetWindowId = await api.window.getFocusedVisibleWindowId(); 459 + if (targetWindowId) { 460 + const modeResult = await api.context.get('mode', targetWindowId); 461 + if (modeResult.success && modeResult.data?.value === 'group' && modeResult.data.metadata?.groupId) { 462 + return { 463 + groupId: modeResult.data.metadata.groupId, 464 + groupName: modeResult.data.metadata.groupName || '' 465 + }; 466 + } 467 + } 468 + } catch (err) { 469 + debug && console.log('[ext:groups] Failed to resolve current group:', err); 470 + } 471 + return null; 472 + }; 473 + 474 + /** Close (suspend) a group — hide all windows in the group context */ 475 + const closeGroup = async (groupId = null, groupName = null) => { 476 + if (!groupId) { 477 + const current = await resolveCurrentGroup(); 478 + if (current) { groupId = current.groupId; groupName = current.groupName; } 479 + } 480 + if (!groupId) return { success: false, error: 'No active group to close' }; 481 + 482 + try { 483 + if (api.session?.saveSpaceWorkspaces) await api.session.saveSpaceWorkspaces(); 484 + } catch (err) { 485 + debug && console.log('[ext:groups] Failed to save workspace before close:', err); 486 + } 487 + 488 + const result = await api.context.getWindowsInSpace(groupId); 489 + if (!result.success || !result.data || result.data.length === 0) { 490 + return { success: false, error: 'No windows found in group' }; 491 + } 492 + 493 + const windowIds = result.data; 494 + const hiddenIds = []; 495 + for (const windowId of windowIds) { 496 + try { 497 + await api.window.hide(windowId); 498 + hiddenIds.push(windowId); 499 + } catch (err) { 500 + debug && console.log(`[ext:groups] Failed to hide window ${windowId}:`, err); 501 + } 502 + } 503 + suspendedGroups.set(groupId, hiddenIds); 504 + 505 + if (activeGroupId === groupId) { 506 + activeGroupId = null; 507 + activeGroupName = null; 508 + } 509 + 510 + console.log(`[ext:groups] Closed group "${groupName || groupId}": hid ${hiddenIds.length} window(s)`); 511 + return { success: true, count: hiddenIds.length, groupId, groupName }; 512 + }; 513 + 514 + /** Restore a suspended group — show all hidden windows */ 515 + const restoreGroup = async (groupId, groupName = null) => { 516 + const hiddenIds = suspendedGroups.get(groupId); 517 + if (!hiddenIds || hiddenIds.length === 0) { 518 + if (groupName) return openGroup(groupName); 519 + return { success: false, error: 'Group not suspended and no name to open' }; 520 + } 521 + 522 + let restoredCount = 0; 523 + const failedIds = []; 524 + for (const windowId of hiddenIds) { 525 + try { 526 + const exists = await api.window.exists(windowId); 527 + if (exists?.exists) { 528 + await api.window.show(windowId); 529 + restoredCount++; 530 + } else { 531 + failedIds.push(windowId); 532 + } 533 + } catch (err) { 534 + debug && console.log(`[ext:groups] Failed to show window ${windowId}:`, err); 535 + failedIds.push(windowId); 536 + } 537 + } 538 + suspendedGroups.delete(groupId); 539 + 540 + if (failedIds.length > 0 && groupName) { 541 + console.log(`[ext:groups] ${failedIds.length} windows destroyed while suspended, re-opening group`); 542 + return openGroup(groupName); 543 + } 544 + 545 + activeGroupId = groupId; 546 + activeGroupName = groupName; 547 + console.log(`[ext:groups] Restored group "${groupName || groupId}": showed ${restoredCount} window(s)`); 548 + return { success: true, count: restoredCount }; 549 + }; 550 + 551 + /** Switch from current group to target group */ 552 + const switchGroup = async (targetGroupName) => { 553 + if (!targetGroupName) return { success: false, error: 'Usage: switch group <name>' }; 554 + const tagsResult = await api.datastore.getTagsByFrecency(); 555 + if (!tagsResult.success) return { success: false, error: 'Failed to get tags' }; 556 + const targetTag = tagsResult.data.find(t => t.name.toLowerCase() === targetGroupName.toLowerCase()); 557 + if (!targetTag) return { success: false, error: `Group "${targetGroupName}" not found` }; 558 + const current = await resolveCurrentGroup(); 559 + if (current && current.groupId !== targetTag.id) { 560 + await closeGroup(current.groupId, current.groupName); 561 + } 562 + const result = await restoreGroup(targetTag.id, targetTag.name); 563 + console.log(`[ext:groups] Switched to group "${targetGroupName}"`); 564 + return result; 565 + }; 566 + 567 + // ===== Pins ===== 568 + 569 + const getPinnedItems = async (groupId) => { 570 + try { 571 + const result = await api.settings.get(`pins:${groupId}`); 572 + if (!result.error && result.value) { 573 + return Array.isArray(result.value) ? result.value : []; 574 + } 575 + } catch (err) { 576 + debug && console.log('[ext:groups] Failed to load pins:', err); 577 + } 578 + return []; 579 + }; 580 + 581 + const savePinnedItems = async (groupId, pinnedItemIds) => { 582 + try { 583 + await api.settings.set(`pins:${groupId}`, pinnedItemIds); 584 + } catch (err) { 585 + console.error('[ext:groups] Failed to save pins:', err); 586 + } 587 + }; 588 + 589 + // Note: normalizeUrlForCompare already defined in home.js (line ~519) — reused below. 590 + 591 + const getOrCreateUrlItem = async (url, title = '') => { 592 + const result = await api.datastore.queryItems({ type: 'url', search: url, limit: 10 }); 593 + if (!result.success) return null; 594 + const normalizedUrl = normalizeUrlForCompare(url); 595 + const existing = result.data.find(item => normalizeUrlForCompare(item.content) === normalizedUrl); 596 + if (existing) return existing; 597 + const addResult = await api.datastore.addItem('url', { 598 + content: url, 599 + metadata: JSON.stringify({ title }) 600 + }); 601 + if (!addResult.success) return null; 602 + return { id: addResult.data.id, content: url }; 603 + }; 604 + 605 + const pinItem = async (url, groupId = null) => { 606 + if (!groupId) { 607 + const current = await resolveCurrentGroup(); 608 + if (current) groupId = current.groupId; 609 + } 610 + if (!groupId) return { success: false, error: 'No active group context — open a group first' }; 611 + if (!url) return { success: false, error: 'Usage: pin <url>' }; 612 + 613 + const item = await getOrCreateUrlItem(url); 614 + if (!item) return { success: false, error: 'Failed to find or create item for URL' }; 615 + 616 + const pins = await getPinnedItems(groupId); 617 + if (!pins.includes(item.id)) { 618 + pins.push(item.id); 619 + await savePinnedItems(groupId, pins); 620 + } 621 + 622 + api.pubsub.publish('group:pin-changed', { groupId, itemId: item.id, pinned: true }, api.scopes.GLOBAL); 623 + console.log(`[ext:groups] Pinned item "${url}" in group ${groupId}`); 624 + return { success: true, itemId: item.id, groupId }; 625 + }; 626 + 627 + const unpinItem = async (url, groupId = null) => { 628 + if (!groupId) { 629 + const current = await resolveCurrentGroup(); 630 + if (current) groupId = current.groupId; 631 + } 632 + if (!groupId) return { success: false, error: 'No active group context' }; 633 + if (!url) return { success: false, error: 'Usage: unpin <url>' }; 634 + 635 + const item = await getOrCreateUrlItem(url); 636 + if (!item) return { success: false, error: 'Item not found' }; 637 + 638 + const pins = await getPinnedItems(groupId); 639 + const idx = pins.indexOf(item.id); 640 + if (idx !== -1) { 641 + pins.splice(idx, 1); 642 + await savePinnedItems(groupId, pins); 643 + } 644 + 645 + api.pubsub.publish('group:pin-changed', { groupId, itemId: item.id, pinned: false }, api.scopes.GLOBAL); 646 + console.log(`[ext:groups] Unpinned item "${url}" from group ${groupId}`); 647 + return { success: true, itemId: item.id, groupId }; 648 + }; 649 + 650 + // ===== Group query/save/open helpers ===== 651 + 652 + const getAllGroups = async () => { 653 + const result = await api.datastore.getTagsByFrecency(); 654 + if (!result.success) return []; 655 + return result.data; 656 + }; 657 + 658 + const saveToGroup = async (groupName) => { 659 + console.log('[ext:groups] Saving to group:', groupName); 660 + const tagResult = await api.datastore.getOrCreateTag(groupName); 661 + if (!tagResult.success) { 662 + console.error('[ext:groups] Failed to get/create tag:', tagResult.error); 663 + return { success: false, error: tagResult.error }; 664 + } 665 + 666 + const tag = tagResult.data.tag; 667 + const tagId = tag.id; 668 + 669 + try { 670 + let meta = {}; 671 + if (tag.metadata) { 672 + meta = typeof tag.metadata === 'object' ? tag.metadata : JSON.parse(tag.metadata); 673 + } 674 + if (!meta.isGroup) { 675 + meta.isGroup = true; 676 + await api.datastore.setRow('tags', tagId, { ...tag, metadata: JSON.stringify(meta) }); 677 + debug && console.log('[ext:groups] Auto-promoted tag to group:', groupName); 678 + } 679 + } catch (err) { 680 + console.error('[ext:groups] Failed to auto-promote tag:', err); 681 + } 682 + 683 + const listResult = await api.window.list({ includeInternal: false }); 684 + if (!listResult.success || listResult.windows.length === 0) { 685 + console.log('[ext:groups] No windows to save'); 686 + return { success: false, error: 'No windows to save' }; 687 + } 688 + 689 + let savedCount = 0; 690 + for (const win of listResult.windows) { 691 + const item = await getOrCreateUrlItem(win.url, win.title); 692 + if (item) { 693 + const linkResult = await api.datastore.tagItem(item.id, tagId); 694 + if (linkResult.success && !linkResult.alreadyExists) savedCount++; 695 + } 696 + } 697 + console.log(`[ext:groups] Saved ${savedCount} URLs to group "${groupName}"`); 698 + 699 + try { 700 + if (api.session?.saveSpaceWorkspaces) { 701 + await api.session.saveSpaceWorkspaces(); 702 + debug && console.log('[ext:groups] Group workspace layouts saved'); 703 + } 704 + } catch (err) { 705 + debug && console.log('[ext:groups] Failed to save workspace layouts:', err); 706 + } 707 + 708 + return { success: true, count: savedCount, total: listResult.windows.length }; 709 + }; 710 + 711 + /** Open all URLs in a group (bulk-open path — NOT the home.js showAddresses path) */ 712 + const openGroup = async (groupName) => { 713 + console.log('[ext:groups] Opening group:', groupName); 714 + const tagsResult = await api.datastore.getTagsByFrecency(); 715 + if (!tagsResult.success) return { success: false, error: 'Failed to get tags' }; 716 + 717 + const tag = tagsResult.data.find(t => t.name.toLowerCase() === groupName.toLowerCase()); 718 + if (!tag) { 719 + console.log('[ext:groups] Group not found:', groupName); 720 + return { success: false, error: 'Group not found' }; 721 + } 722 + 723 + const itemsResult = await api.datastore.getItemsByTag(tag.id); 724 + if (!itemsResult.success) { 725 + console.log('[ext:groups] Failed to get items for group:', groupName); 726 + return { success: false, error: 'Failed to get group items' }; 727 + } 728 + 729 + const allUrlItems = itemsResult.data 730 + .map(item => { 731 + if (item.type === 'url') return { ...item, _openUrl: item.content }; 732 + if (item.type === 'text' && item.content) { 733 + const urlMatch = item.content.trim().match(/^https?:\/\/\S+/) || item.content.match(/https?:\/\/[^\s<>"')\]]+/i); 734 + if (urlMatch) { 735 + try { new URL(urlMatch[0]); return { ...item, _openUrl: urlMatch[0] }; } catch (e) {} 736 + } 737 + } 738 + return null; 739 + }) 740 + .filter(Boolean); 741 + 742 + const seenUrls = new Set(); 743 + const urlItems = allUrlItems.filter(item => { 744 + const normalized = normalizeUrlForCompare(item._openUrl); 745 + if (seenUrls.has(normalized)) return false; 746 + seenUrls.add(normalized); 747 + return true; 748 + }); 749 + 750 + if (urlItems.length === 0) { 751 + console.log('[ext:groups] No URLs in group:', groupName); 752 + return { success: false, error: 'Group is empty' }; 753 + } 754 + 755 + const pinnedItemIds = await getPinnedItems(tag.id); 756 + const pinnedSet = new Set(pinnedItemIds); 757 + 758 + activeGroupId = tag.id; 759 + activeGroupName = tag.name; 760 + 761 + let savedBoundsMap = null; 762 + let sortedUrlItems = urlItems; 763 + try { 764 + const wsResult = api.settings.getExtKey 765 + ? await api.settings.getExtKey('spaces', 'workspace:' + tag.id) 766 + : { success: false }; 767 + if (wsResult.success && wsResult.data) { 768 + const snapshot = typeof wsResult.data === 'string' ? JSON.parse(wsResult.data) : wsResult.data; 769 + if (snapshot.version === 1 && Array.isArray(snapshot.windows)) { 770 + savedBoundsMap = new Map(); 771 + for (const w of snapshot.windows) { 772 + if (w.url && w.bounds) { 773 + savedBoundsMap.set(normalizeUrlForCompare(w.url), { bounds: w.bounds, zOrder: w.zOrder ?? 0 }); 774 + } 775 + } 776 + sortedUrlItems = [...urlItems].sort((a, b) => { 777 + const aPinned = pinnedSet.has(a.id) ? 1 : 0; 778 + const bPinned = pinnedSet.has(b.id) ? 1 : 0; 779 + if (aPinned !== bPinned) return bPinned - aPinned; 780 + const aZ = savedBoundsMap.get(normalizeUrlForCompare(a._openUrl))?.zOrder ?? 0; 781 + const bZ = savedBoundsMap.get(normalizeUrlForCompare(b._openUrl))?.zOrder ?? 0; 782 + return bZ - aZ; 783 + }); 784 + debug && console.log(`[ext:groups] Loaded workspace snapshot with ${snapshot.windows.length} saved positions`); 785 + } 786 + } 787 + } catch (err) { 788 + debug && console.log('[ext:groups] No saved workspace layout, using defaults:', err); 789 + } 790 + 791 + const openedWindows = []; 792 + for (const item of sortedUrlItems) { 793 + const savedEntry = savedBoundsMap?.get(normalizeUrlForCompare(item._openUrl)); 794 + const boundsOpts = savedEntry?.bounds ? { 795 + x: savedEntry.bounds.x, 796 + y: savedEntry.bounds.y, 797 + width: savedEntry.bounds.width, 798 + height: savedEntry.bounds.height, 799 + } : {}; 800 + const result = await api.window.open(item._openUrl, { 801 + role: 'content', 802 + trackingSource: 'cmd', 803 + trackingSourceId: `group:${groupName}`, 804 + groupMode: { groupId: tag.id, groupName: tag.name, color: tag.color }, 805 + ...boundsOpts 806 + }); 807 + if (result?.id) { 808 + openedWindows.push(result.id); 809 + await setGroupMode(result.id, tag.id, tag.name, tag.color); 810 + } 811 + } 812 + 813 + console.log(`[ext:groups] Opened ${urlItems.length} windows from group "${groupName}"`); 814 + return { success: true, count: urlItems.length, windowIds: openedWindows }; 815 + }; 816 + 817 + // ===== Registration ===== 818 + 819 + let registeredShortcut = null; 820 + const LOCAL_SHORTCUT = 'CommandOrControl+G'; 821 + 822 + const initShortcut = (shortcut) => { 823 + api.shortcuts.register(shortcut, () => { openGroupsWindow(); }, { global: true }); 824 + registeredShortcut = shortcut; 825 + api.shortcuts.register(LOCAL_SHORTCUT, () => { openGroupsWindow(); }); 826 + }; 827 + 828 + const initCommands = () => { 829 + registerNoun({ 830 + name: 'groups', 831 + singular: 'group', 832 + description: 'Saved tab groups', 833 + 834 + query: async ({ search }) => { 835 + const current = await resolveCurrentGroup(); 836 + const groups = await getAllGroups(); 837 + const withCounts = await Promise.all(groups.map(async g => { 838 + const result = await api.datastore.getItemsByTag(g.id); 839 + const count = result.success ? result.data.filter(i => i.type === 'url').length : 0; 840 + return { id: g.id, name: g.name, color: g.color, count }; 841 + })); 842 + let filtered = withCounts.filter(g => g.count > 0); 843 + if (search) { 844 + const s = search.toLowerCase(); 845 + filtered = filtered.filter(g => g.name.toLowerCase().includes(s)); 846 + } 847 + 848 + if (current) { 849 + const activeIdx = filtered.findIndex(g => g.id === current.groupId); 850 + if (activeIdx > 0) { 851 + const [active] = filtered.splice(activeIdx, 1); 852 + active.name = `${active.name} (active)`; 853 + filtered.unshift(active); 854 + } 855 + } 856 + 857 + if (filtered.length === 0) return { output: 'No groups found.', mimeType: 'text/plain' }; 858 + 859 + const title = current 860 + ? `Groups (${filtered.length}) [scope: ${current.groupName}]` 861 + : `Groups (${filtered.length})`; 862 + 863 + return { 864 + success: true, 865 + output: { data: filtered, mimeType: 'application/json', title } 866 + }; 867 + }, 868 + 869 + browse: async () => { openGroupsWindow(); }, 870 + open: async (ctx) => { if (ctx.search) await openGroup(ctx.search.trim()); }, 871 + create: async ({ search }) => { 872 + if (!search) return { success: false, error: 'Usage: new group <name>' }; 873 + return await saveToGroup(search.trim()); 874 + }, 875 + 876 + produces: 'application/json' 877 + }); 878 + console.log('[ext:groups] Noun registered: groups'); 879 + 880 + // pin <url> 881 + api.pubsub.subscribe('cmd:execute:pin', async (msg) => { 882 + const result = await pinItem(msg.search?.trim()); 883 + if (msg.expectResult && msg.resultTopic) { 884 + api.pubsub.publish(msg.resultTopic, result || { success: true }, api.scopes.GLOBAL); 885 + } 886 + }, api.scopes.GLOBAL); 887 + api.pubsub.publish('cmd:register', { 888 + name: 'pin', 889 + description: 'Pin a URL in the current group (always opens with group)', 890 + source: 'groups', 891 + scope: 'global', 892 + accepts: [], 893 + produces: [], 894 + params: [{ name: 'url', type: 'string', required: true, description: 'URL to pin' }] 895 + }, api.scopes.GLOBAL); 896 + 897 + // unpin <url> 898 + api.pubsub.subscribe('cmd:execute:unpin', async (msg) => { 899 + const result = await unpinItem(msg.search?.trim()); 900 + if (msg.expectResult && msg.resultTopic) { 901 + api.pubsub.publish(msg.resultTopic, result || { success: true }, api.scopes.GLOBAL); 902 + } 903 + }, api.scopes.GLOBAL); 904 + api.pubsub.publish('cmd:register', { 905 + name: 'unpin', 906 + description: 'Unpin a URL from the current group', 907 + source: 'groups', 908 + scope: 'global', 909 + accepts: [], 910 + produces: [], 911 + params: [{ name: 'url', type: 'string', required: true, description: 'URL to unpin' }] 912 + }, api.scopes.GLOBAL); 913 + 914 + console.log('[ext:groups] Standalone commands registered: pin, unpin'); 915 + }; 916 + 917 + const uninitCommands = () => { 918 + unregisterNoun('groups'); 919 + for (const name of ['pin', 'unpin']) { 920 + api.pubsub.publish('cmd:unregister', { name }, api.scopes.GLOBAL); 921 + } 922 + console.log('[ext:groups] Noun and commands unregistered: groups'); 923 + }; 924 + 925 + const initBackground = async () => { 926 + console.log('[ext:groups] init'); 927 + currentSettings = await loadSettings(); 928 + initShortcut(currentSettings.prefs.shortcutKey); 929 + initCommands(); 930 + 931 + api.pubsub.subscribe('window:closed', async (msg) => { 932 + const closedWindowId = msg?.id; 933 + if (closedWindowId === groupsWindowId) { 934 + debug && console.log('[ext:groups] Groups window closed, keeping group mode on member windows'); 935 + groupsWindowId = null; 936 + } 937 + }, api.scopes.GLOBAL); 938 + 939 + api.pubsub.subscribe('groups:settings-changed', async () => { 940 + console.log('[ext:groups] settings changed, reinitializing'); 941 + uninitBackground(); 942 + currentSettings = await loadSettings(); 943 + initShortcut(currentSettings.prefs.shortcutKey); 944 + initCommands(); 945 + }, api.scopes.GLOBAL); 946 + 947 + api.pubsub.subscribe('groups:settings-update', async (msg) => { 948 + console.log('[ext:groups] settings-update received:', msg); 949 + try { 950 + if (msg.data) { 951 + currentSettings = { prefs: msg.data.prefs || currentSettings.prefs }; 952 + } else if (msg.key === 'prefs' && msg.path) { 953 + const field = msg.path.split('.')[1]; 954 + if (field) { 955 + currentSettings.prefs = { ...currentSettings.prefs, [field]: msg.value }; 956 + } 957 + } 958 + await saveSettings(currentSettings); 959 + uninitBackground(); 960 + initShortcut(currentSettings.prefs.shortcutKey); 961 + initCommands(); 962 + api.pubsub.publish('groups:settings-changed', currentSettings, api.scopes.GLOBAL); 963 + } catch (err) { 964 + console.error('[ext:groups] settings-update error:', err); 965 + } 966 + }, api.scopes.GLOBAL); 967 + }; 968 + 969 + const uninitBackground = () => { 970 + console.log('[ext:groups] uninit'); 971 + if (registeredShortcut) { 972 + api.shortcuts.unregister(registeredShortcut, { global: true }); 973 + registeredShortcut = null; 974 + } 975 + api.shortcuts.unregister(LOCAL_SHORTCUT); 976 + uninitCommands(); 977 + }; 978 + 979 + // ============================================================================ 980 + // Bootstrap (module top-level) 981 + // ============================================================================ 982 + // Runs bg init once at module load. Does NOT block DOMContentLoaded — uses 983 + // fire-and-forget IIFE so the module keeps parsing (registering the 984 + // DOMContentLoaded listener for `init` at the bottom of this file). 985 + // Errors here are logged but do not prevent UI init from running. 986 + 987 + (async () => { 988 + try { 989 + await api.initialize(); 990 + await initBackground(); 991 + api.onShutdown(() => { 992 + console.log('[ext:groups] received shutdown'); 993 + uninitBackground(); 994 + }); 995 + } catch (err) { 996 + console.error('[ext:groups] bootstrap failed:', err); 997 + } 998 + })(); 351 999 352 1000 const init = async () => { 353 1001 debug && console.log('Groups init');
+5 -14
features/groups/manifest.json
··· 8 8 "builtin": true, 9 9 "tiles": [ 10 10 { 11 - "id": "background", 12 - "type": "background", 13 - "url": "background.html", 14 - "lazy": true 15 - }, 16 - { 17 11 "id": "home", 18 - "type": "window", 19 12 "url": "home.html", 20 - "windowHints": { 21 - "role": "workspace", 22 - "key": "peek://ext/groups/home.html", 23 - "width": 800, 24 - "height": 600, 25 - "title": "Groups" 26 - } 13 + "width": 800, 14 + "height": 600, 15 + "title": "Groups", 16 + "role": "workspace", 17 + "key": "peek://ext/groups/home.html" 27 18 } 28 19 ], 29 20 "capabilities": {