experiments in a post-browser web
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};