experiments in a post-browser web
at main 882 lines 27 kB view raw
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 10import { id, labels, schemas, storageKeys, defaults } from './config.js'; 11import { registerNoun, unregisterNoun } from 'peek://ext/cmd/nouns.js'; 12 13const api = window.app; 14const debug = api.debug; 15 16console.log('[ext:groups] background', labels.name); 17 18// Extension content is served from peek://ext/groups/ 19const address = 'peek://ext/groups/home.html'; 20 21// In-memory settings cache (loaded from datastore on init) 22let currentSettings = { 23 prefs: defaults.prefs 24}; 25 26// Track the groups window ID for mode cleanup 27let groupsWindowId = null; 28 29// Track the current active group context 30let activeGroupId = null; 31let activeGroupName = null; 32 33// Track suspended (hidden) group windows: groupId -> [windowId, ...] 34const suspendedGroups = new Map(); 35 36/** 37 * Load settings from datastore 38 * @returns {Promise<{prefs: object}>} 39 */ 40const loadSettings = async () => { 41 const result = await api.settings.get(); 42 if (result.success && result.data) { 43 return { 44 prefs: result.data.prefs || 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 */ 54const saveSettings = async (settings) => { 55 const result = await api.settings.set(settings); 56 if (!result.success) { 57 console.error('[ext:groups] Failed to save settings:', result.error); 58 } 59}; 60 61let isOpeningGroups = false; 62const 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 */ 93const 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('space', { 101 windowId, 102 metadata: { 103 spaceId: groupId, 104 spaceName: groupName, 105 color 106 } 107 }); 108 debug && console.log(`[ext:groups] Set space 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 */ 118const 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 */ 145const 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 === 'space' && modeResult.data.metadata?.spaceId) { 155 return { 156 groupId: modeResult.data.metadata.spaceId, 157 groupName: modeResult.data.metadata.spaceName || '' 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 */ 175const 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 */ 234const 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 */ 287const 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 */ 321const getPinnedItems = async (groupId) => { 322 try { 323 const result = await api.settings.getExtKey('groups', `pins:${groupId}`); 324 if (result.success && result.data) { 325 return Array.isArray(result.data) ? result.data : []; 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 */ 336const savePinnedItems = async (groupId, pinnedItemIds) => { 337 try { 338 await api.settings.setKey(`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 */ 348const 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.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 */ 388const 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.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 */ 430const 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 445const 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 */ 466const 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 */ 475const 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 */ 538const 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 const wsResult = await api.settings.getExtKey('spaces', 'workspace:' + tag.id); 600 if (wsResult.success && wsResult.data) { 601 const snapshot = typeof wsResult.data === 'string' ? JSON.parse(wsResult.data) : wsResult.data; 602 if (snapshot.version === 1 && Array.isArray(snapshot.windows)) { 603 // Build URL -> bounds map 604 savedBoundsMap = new Map(); 605 for (const w of snapshot.windows) { 606 if (w.url && w.bounds) { 607 savedBoundsMap.set(normalizeUrlForCompare(w.url), { bounds: w.bounds, zOrder: w.zOrder ?? 0 }); 608 } 609 } 610 // Sort items: pinned first, then by saved z-order (highest zOrder = opened later = on top) 611 sortedUrlItems = [...urlItems].sort((a, b) => { 612 const aPinned = pinnedSet.has(a.id) ? 1 : 0; 613 const bPinned = pinnedSet.has(b.id) ? 1 : 0; 614 if (aPinned !== bPinned) return bPinned - aPinned; // pinned first 615 const aZ = savedBoundsMap.get(normalizeUrlForCompare(a._openUrl))?.zOrder ?? 0; 616 const bZ = savedBoundsMap.get(normalizeUrlForCompare(b._openUrl))?.zOrder ?? 0; 617 return bZ - aZ; 618 }); 619 debug && console.log(`[ext:groups] Loaded workspace snapshot with ${snapshot.windows.length} saved positions`); 620 } 621 } 622 } catch (err) { 623 debug && console.log('[ext:groups] No saved workspace layout, using defaults:', err); 624 } 625 626 // Open windows and set group mode 627 const openedWindows = []; 628 for (const item of sortedUrlItems) { 629 // Look up saved bounds for this URL 630 const savedEntry = savedBoundsMap?.get(normalizeUrlForCompare(item._openUrl)); 631 const boundsOpts = savedEntry?.bounds ? { 632 x: savedEntry.bounds.x, 633 y: savedEntry.bounds.y, 634 width: savedEntry.bounds.width, 635 height: savedEntry.bounds.height, 636 } : {}; 637 638 const result = await api.window.open(item._openUrl, { 639 role: 'content', 640 trackingSource: 'cmd', 641 trackingSourceId: `group:${groupName}`, 642 // Pass space context for mode inheritance 643 spaceMode: { 644 spaceId: tag.id, 645 spaceName: tag.name, 646 color: tag.color 647 }, 648 ...boundsOpts 649 }); 650 if (result?.id) { 651 openedWindows.push(result.id); 652 // Set group mode for the opened window 653 await setGroupMode(result.id, tag.id, tag.name, tag.color); 654 } 655 } 656 657 console.log(`[ext:groups] Opened ${urlItems.length} windows from group "${groupName}"`); 658 return { success: true, count: urlItems.length, windowIds: openedWindows }; 659}; 660 661// ===== Registration ===== 662 663let registeredShortcut = null; 664const LOCAL_SHORTCUT = 'CommandOrControl+G'; 665 666const initShortcut = (shortcut) => { 667 api.shortcuts.register(shortcut, () => { 668 openGroupsWindow(); 669 }, { global: true }); 670 registeredShortcut = shortcut; 671 672 // Local shortcut (Cmd+G) — works when a Peek window is focused 673 api.shortcuts.register(LOCAL_SHORTCUT, () => { 674 openGroupsWindow(); 675 }); 676}; 677 678const initCommands = () => { 679 registerNoun({ 680 name: 'groups', 681 singular: 'group', 682 description: 'Saved tab groups', 683 684 query: async ({ search }) => { 685 // Group-scoped search: when in group mode, filter items by the active group tag 686 const current = await resolveCurrentGroup(); 687 688 const groups = await getAllGroups(); 689 const withCounts = await Promise.all(groups.map(async g => { 690 const result = await api.datastore.getItemsByTag(g.id); 691 const count = result.success ? result.data.filter(i => i.type === 'url').length : 0; 692 return { id: g.id, name: g.name, color: g.color, count }; 693 })); 694 let filtered = withCounts.filter(g => g.count > 0); 695 if (search) { 696 const s = search.toLowerCase(); 697 filtered = filtered.filter(g => g.name.toLowerCase().includes(s)); 698 } 699 700 // If in group mode, highlight the active group by moving it to the top 701 if (current) { 702 const activeIdx = filtered.findIndex(g => g.id === current.groupId); 703 if (activeIdx > 0) { 704 const [active] = filtered.splice(activeIdx, 1); 705 active.name = `${active.name} (active)`; 706 filtered.unshift(active); 707 } 708 } 709 710 if (filtered.length === 0) { 711 return { output: 'No groups found.', mimeType: 'text/plain' }; 712 } 713 714 // Add search scope indicator when in group mode 715 const title = current 716 ? `Groups (${filtered.length}) [scope: ${current.groupName}]` 717 : `Groups (${filtered.length})`; 718 719 return { 720 success: true, 721 output: { 722 data: filtered, 723 mimeType: 'application/json', 724 title 725 } 726 }; 727 }, 728 729 browse: async () => { openGroupsWindow(); }, 730 731 open: async (ctx) => { 732 if (ctx.search) await openGroup(ctx.search.trim()); 733 }, 734 735 create: async ({ search }) => { 736 if (!search) return { success: false, error: 'Usage: new group <name>' }; 737 return await saveToGroup(search.trim()); 738 }, 739 740 produces: 'application/json' 741 }); 742 743 console.log('[ext:groups] Noun registered: groups'); 744 745 // ===== Standalone commands (pin, unpin) ===== 746 // Workspace commands (close, switch, restore) moved to spaces feature. 747 748 // "pin <url>" — mark a URL as pinned in the current group 749 api.subscribe('cmd:execute:pin', async (msg) => { 750 const result = await pinItem(msg.search?.trim()); 751 if (msg.expectResult && msg.resultTopic) { 752 api.publish(msg.resultTopic, result || { success: true }, api.scopes.GLOBAL); 753 } 754 }, api.scopes.GLOBAL); 755 api.publish('cmd:register', { 756 name: 'pin', 757 description: 'Pin a URL in the current group (always opens with group)', 758 source: 'groups', 759 scope: 'global', 760 accepts: [], 761 produces: [], 762 params: [{ name: 'url', type: 'string', required: true, description: 'URL to pin' }] 763 }, api.scopes.GLOBAL); 764 765 // "unpin <url>" — remove pin from a URL in the current group 766 api.subscribe('cmd:execute:unpin', async (msg) => { 767 const result = await unpinItem(msg.search?.trim()); 768 if (msg.expectResult && msg.resultTopic) { 769 api.publish(msg.resultTopic, result || { success: true }, api.scopes.GLOBAL); 770 } 771 }, api.scopes.GLOBAL); 772 api.publish('cmd:register', { 773 name: 'unpin', 774 description: 'Unpin a URL from the current group', 775 source: 'groups', 776 scope: 'global', 777 accepts: [], 778 produces: [], 779 params: [{ name: 'url', type: 'string', required: true, description: 'URL to unpin' }] 780 }, api.scopes.GLOBAL); 781 782 console.log('[ext:groups] Standalone commands registered: pin, unpin'); 783}; 784 785const uninitCommands = () => { 786 unregisterNoun('groups'); 787 // Unregister standalone commands 788 for (const name of ['pin', 'unpin']) { 789 api.publish('cmd:unregister', { name }, api.scopes.GLOBAL); 790 } 791 console.log('[ext:groups] Noun and commands unregistered: groups'); 792}; 793 794const init = async () => { 795 console.log('[ext:groups] init'); 796 797 // Load settings from datastore 798 currentSettings = await loadSettings(); 799 800 initShortcut(currentSettings.prefs.shortcutKey); 801 802 // Register commands (cmd loads first with its subscribers ready via 100ms head start) 803 initCommands(); 804 805 // Listen for window close events to clean up groups window tracking 806 // NOTE: We do NOT exit group mode when the groups panel closes. 807 // Group mode persists on member windows. It is only cleared when: 808 // - The user navigates back to the groups list (handled in home.js showGroups) 809 // - The user explicitly changes mode 810 api.subscribe('window:closed', async (msg) => { 811 const closedWindowId = msg?.id; 812 813 if (closedWindowId === groupsWindowId) { 814 debug && console.log('[ext:groups] Groups window closed, keeping group mode on member windows'); 815 groupsWindowId = null; 816 } 817 }, api.scopes.GLOBAL); 818 819 // Listen for settings changes to hot-reload (GLOBAL scope for cross-process) 820 api.subscribe('groups:settings-changed', async () => { 821 console.log('[ext:groups] settings changed, reinitializing'); 822 uninit(); 823 currentSettings = await loadSettings(); 824 initShortcut(currentSettings.prefs.shortcutKey); 825 initCommands(); 826 }, api.scopes.GLOBAL); 827 828 // Listen for settings updates from Settings UI 829 // Settings UI sends proposed changes, we validate and save 830 api.subscribe('groups:settings-update', async (msg) => { 831 console.log('[ext:groups] settings-update received:', msg); 832 833 try { 834 // Apply the update based on what was sent 835 if (msg.data) { 836 // Full data object sent 837 currentSettings = { 838 prefs: msg.data.prefs || currentSettings.prefs 839 }; 840 } else if (msg.key === 'prefs' && msg.path) { 841 // Single pref field update 842 const field = msg.path.split('.')[1]; 843 if (field) { 844 currentSettings.prefs = { ...currentSettings.prefs, [field]: msg.value }; 845 } 846 } 847 848 // Save to datastore 849 await saveSettings(currentSettings); 850 851 // Reinitialize with new settings 852 uninit(); 853 initShortcut(currentSettings.prefs.shortcutKey); 854 initCommands(); 855 856 // Confirm change back to Settings UI 857 api.publish('groups:settings-changed', currentSettings, api.scopes.GLOBAL); 858 } catch (err) { 859 console.error('[ext:groups] settings-update error:', err); 860 } 861 }, api.scopes.GLOBAL); 862}; 863 864const uninit = () => { 865 console.log('[ext:groups] uninit'); 866 if (registeredShortcut) { 867 api.shortcuts.unregister(registeredShortcut, { global: true }); 868 registeredShortcut = null; 869 } 870 api.shortcuts.unregister(LOCAL_SHORTCUT); 871 uninitCommands(); 872}; 873 874export default { 875 defaults, 876 id, 877 init, 878 uninit, 879 labels, 880 schemas, 881 storageKeys 882};