experiments in a post-browser web
10
fork

Configure Feed

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

feat(reactivity): add tag-centric events for real-time UI updates

IPC layer (backend/electron/ipc.ts):
- Emit tag:created when getOrCreateTag creates new tag
- Emit tag:item-added/removed on tagItem/untagItem
- Emit tag:address-added/removed on tagAddress/untagAddress
- Events include tagId, tagName, itemId/addressId, itemType

Extensions:
- groups/home.js: Subscribe to tag events, refresh on changes
- tags/home.js: Targeted state updates on tag events (not full reload)
- cmd/panel.js: Fix mode indicator with modeWasSet flag

Groups now sets mode context when navigating into a group view.

+181 -5
+67
backend/electron/ipc.ts
··· 387 387 ipcMain.handle('datastore-get-or-create-tag', async (ev, data) => { 388 388 try { 389 389 const result = getOrCreateTag(data.name); 390 + 391 + // Emit event if tag was newly created 392 + if (result.created) { 393 + publish('system', PubSubScopes.GLOBAL, 'tag:created', { 394 + tagId: result.tag.id, 395 + tagName: result.tag.name 396 + }); 397 + if (DEBUG) console.log('[ipc] tag:created', result.tag.id, result.tag.name); 398 + } 399 + 390 400 return { success: true, data: result }; 391 401 } catch (error) { 392 402 const message = error instanceof Error ? error.message : String(error); ··· 397 407 ipcMain.handle('datastore-tag-address', async (ev, data) => { 398 408 try { 399 409 const result = tagAddress(data.addressId, data.tagId); 410 + 411 + // Emit event if this is a new link (not already tagged) 412 + if (!result.alreadyExists) { 413 + // Fetch tag name for event payload 414 + const tag = getDb().prepare('SELECT name FROM tags WHERE id = ?').get(data.tagId) as { name: string } | undefined; 415 + publish('system', PubSubScopes.GLOBAL, 'tag:address-added', { 416 + tagId: data.tagId, 417 + tagName: tag?.name, 418 + addressId: data.addressId 419 + }); 420 + if (DEBUG) console.log('[ipc] tag:address-added', data.tagId, data.addressId); 421 + } 422 + 400 423 return { success: true, data: result }; 401 424 } catch (error) { 402 425 const message = error instanceof Error ? error.message : String(error); ··· 406 429 407 430 ipcMain.handle('datastore-untag-address', async (ev, data) => { 408 431 try { 432 + // Fetch tag name before untagging (need it for event payload) 433 + const tag = getDb().prepare('SELECT name FROM tags WHERE id = ?').get(data.tagId) as { name: string } | undefined; 434 + 409 435 const result = untagAddress(data.addressId, data.tagId); 436 + 437 + // Emit event if the untag actually removed something 438 + if (result) { 439 + publish('system', PubSubScopes.GLOBAL, 'tag:address-removed', { 440 + tagId: data.tagId, 441 + tagName: tag?.name, 442 + addressId: data.addressId 443 + }); 444 + if (DEBUG) console.log('[ipc] tag:address-removed', data.tagId, data.addressId); 445 + } 446 + 410 447 return { success: true, data: result }; 411 448 } catch (error) { 412 449 const message = error instanceof Error ? error.message : String(error); ··· 518 555 ipcMain.handle('datastore-tag-item', async (ev, data) => { 519 556 try { 520 557 const result = tagItem(data.itemId, data.tagId); 558 + 559 + // Emit event if this is a new link (not already tagged) 560 + if (!result.alreadyExists) { 561 + // Fetch tag name and item type for event payload 562 + const db = getDb(); 563 + const tag = db.prepare('SELECT name FROM tags WHERE id = ?').get(data.tagId) as { name: string } | undefined; 564 + const item = db.prepare('SELECT type FROM items WHERE id = ?').get(data.itemId) as { type: string } | undefined; 565 + publish('system', PubSubScopes.GLOBAL, 'tag:item-added', { 566 + tagId: data.tagId, 567 + tagName: tag?.name, 568 + itemId: data.itemId, 569 + itemType: item?.type 570 + }); 571 + if (DEBUG) console.log('[ipc] tag:item-added', data.tagId, data.itemId); 572 + } 573 + 521 574 return { success: true, data: result }; 522 575 } catch (error) { 523 576 const message = error instanceof Error ? error.message : String(error); ··· 527 580 528 581 ipcMain.handle('datastore-untag-item', async (ev, data) => { 529 582 try { 583 + // Fetch tag name before untagging (need it for event payload) 584 + const tag = getDb().prepare('SELECT name FROM tags WHERE id = ?').get(data.tagId) as { name: string } | undefined; 585 + 530 586 const result = untagItem(data.itemId, data.tagId); 587 + 588 + // Emit event if the untag actually removed something 589 + if (result) { 590 + publish('system', PubSubScopes.GLOBAL, 'tag:item-removed', { 591 + tagId: data.tagId, 592 + tagName: tag?.name, 593 + itemId: data.itemId 594 + }); 595 + if (DEBUG) console.log('[ipc] tag:item-removed', data.tagId, data.itemId); 596 + } 597 + 531 598 return { success: true, data: result }; 532 599 } catch (error) { 533 600 const message = error instanceof Error ? error.message : String(error);
+18 -5
extensions/cmd/panel.js
··· 105 105 // Current mode for UI display 106 106 let currentMode = 'default'; 107 107 let currentModeMetadata = {}; 108 + // Flag to track if mode was explicitly set (vs still at initial 'default') 109 + let modeWasSet = false; 108 110 109 111 /** 110 112 * Load the current command context (target window, mode state) ··· 121 123 log('cmd:panel', 'Transient invocation - using default mode'); 122 124 currentMode = 'default'; 123 125 currentModeMetadata = {}; 126 + modeWasSet = true; // Explicitly set by transient detection 124 127 updateModeIndicator(); 125 128 // Still load command context for backwards compat, but don't use its mode 126 129 const result = await api.modes.getCommandContext(); ··· 140 143 if (modeResult.success && modeResult.data) { 141 144 currentMode = modeResult.data.value || 'default'; 142 145 currentModeMetadata = modeResult.data.metadata || {}; 146 + modeWasSet = true; // Explicitly set from context API 143 147 log('cmd:panel', 'Loaded mode from context:', currentMode, currentModeMetadata); 144 148 updateModeIndicator(); 145 149 } ··· 150 154 if (result.success) { 151 155 commandContext = result.data; 152 156 log('cmd:panel', 'Loaded command context:', commandContext); 153 - // Update mode from context if not already set via context API 154 - if (!currentMode && commandContext?.mode?.major) { 157 + // Update mode from old API if context API didn't set a mode 158 + if (!modeWasSet && commandContext?.mode?.major) { 155 159 currentMode = commandContext.mode.major; 160 + modeWasSet = true; 156 161 updateModeIndicator(); 157 162 } 158 163 } ··· 225 230 async function initModeIndicator() { 226 231 // Use commandContext which has the target window's mode (not the cmd panel's mode) 227 232 // commandContext is loaded by loadCommandContext() before this is called 228 - // BUT: Don't override if loadCommandContext already set 'default' (transient mode) 229 - if (currentMode === 'default') { 230 - // Already set by transient detection - don't override 233 + // If mode was explicitly set (by transient detection or context API), use that 234 + // Otherwise try to get mode from commandContext (legacy modes API) 235 + if (modeWasSet) { 236 + // Mode was explicitly set by loadCommandContext - use it 231 237 updateModeIndicator(); 232 238 } else if (commandContext?.mode?.major) { 239 + // Fallback to legacy modes API 233 240 currentMode = commandContext.mode.major; 241 + modeWasSet = true; 234 242 updateModeIndicator(); 235 243 } else { 244 + // No mode data available - stay at 'default' 236 245 updateModeIndicator(); 237 246 } 238 247 ··· 390 399 } 391 400 // Reset showResults 392 401 state.showResults = false; 402 + // Reset mode state for fresh context loading 403 + currentMode = 'default'; 404 + currentModeMetadata = {}; 405 + modeWasSet = false; 393 406 // Load current command context (mode, target window) 394 407 await loadCommandContext(); 395 408 }
+52
extensions/groups/home.js
··· 214 214 // Keyboard navigation 215 215 document.addEventListener('keydown', handleKeydown); 216 216 217 + // Subscribe to tag events for reactive updates 218 + api.subscribe('tag:item-added', async (msg) => { 219 + debug && console.log('[groups] tag:item-added event received:', msg); 220 + // Reload tag counts (an item was tagged) 221 + await loadTags(); 222 + if (state.view === VIEW_GROUPS) renderGroups(); 223 + else if (state.view === VIEW_ADDRESSES && state.currentTag?.name === msg.tagName) { 224 + renderAddresses(); 225 + } 226 + }, api.scopes.GLOBAL); 227 + 228 + api.subscribe('tag:item-removed', async (msg) => { 229 + debug && console.log('[groups] tag:item-removed event received:', msg); 230 + await loadTags(); 231 + if (state.view === VIEW_GROUPS) renderGroups(); 232 + else if (state.view === VIEW_ADDRESSES && state.currentTag?.name === msg.tagName) { 233 + renderAddresses(); 234 + } 235 + }, api.scopes.GLOBAL); 236 + 237 + api.subscribe('tag:created', async (msg) => { 238 + debug && console.log('[groups] tag:created event received:', msg); 239 + await loadTags(); 240 + if (state.view === VIEW_GROUPS) renderGroups(); 241 + }, api.scopes.GLOBAL); 242 + 217 243 // Show groups view 218 244 showGroups(); 219 245 }; ··· 322 348 state.currentTag = null; 323 349 state.searchQuery = ''; 324 350 351 + // Reset mode context to 'default' when returning to groups list 352 + if (api.context) { 353 + try { 354 + await api.context.setMode('default', {}); 355 + debug && console.log('[groups] Reset to default mode'); 356 + } catch (err) { 357 + console.error('[groups] Failed to reset mode:', err); 358 + } 359 + } 360 + 325 361 // Refresh tags 326 362 await loadTags(); 327 363 ··· 381 417 state.view = VIEW_ADDRESSES; 382 418 state.currentTag = tag; 383 419 state.searchQuery = ''; 420 + 421 + // Set mode context to 'group' so cmd bar shows correct mode 422 + if (api.context) { 423 + try { 424 + await api.context.setMode('group', { 425 + metadata: { 426 + groupId: tag.id, 427 + groupName: tag.name, 428 + color: tag.color 429 + } 430 + }); 431 + debug && console.log('[groups] Set group mode:', tag.name); 432 + } catch (err) { 433 + console.error('[groups] Failed to set group mode:', err); 434 + } 435 + } 384 436 385 437 // Load URL items - handle special untagged group (only http/https URLs) 386 438 if (tag.isSpecial && tag.id === '__untagged__') {
+44
extensions/tags/home.js
··· 101 101 }; 102 102 103 103 /** 104 + * Load just the tags list (for incremental updates) 105 + */ 106 + const loadTags = async () => { 107 + const tagsResult = await api.datastore.getTagsByFrecency(); 108 + if (tagsResult.success) { 109 + state.tags = tagsResult.data; 110 + debug && console.log('[tags] Reloaded tags:', state.tags.length); 111 + } else { 112 + console.error('[tags] Failed to reload tags:', tagsResult.error); 113 + } 114 + }; 115 + 116 + /** 104 117 * Set up all event listeners 105 118 */ 106 119 const setupEventListeners = () => { 120 + // Subscribe to tag events for reactive updates 121 + api.subscribe('tag:item-added', async (msg) => { 122 + debug && console.log('[tags] tag:item-added event received:', msg); 123 + // Update the specific item's tags in state 124 + const currentTags = state.itemTags.get(msg.itemId) || []; 125 + if (!currentTags.find(t => t.id === msg.tagId)) { 126 + currentTags.push({ id: msg.tagId, name: msg.tagName }); 127 + state.itemTags.set(msg.itemId, currentTags); 128 + } 129 + // Refresh tag list if new tag 130 + if (!state.tags.find(t => t.id === msg.tagId)) { 131 + await loadTags(); // Just reload tags, not all data 132 + } 133 + render(); 134 + }, api.scopes.GLOBAL); 135 + 136 + api.subscribe('tag:item-removed', async (msg) => { 137 + debug && console.log('[tags] tag:item-removed event received:', msg); 138 + const currentTags = state.itemTags.get(msg.itemId) || []; 139 + state.itemTags.set(msg.itemId, currentTags.filter(t => t.id !== msg.tagId)); 140 + render(); 141 + }, api.scopes.GLOBAL); 142 + 143 + api.subscribe('tag:created', async (msg) => { 144 + debug && console.log('[tags] tag:created event received:', msg); 145 + if (!state.tags.find(t => t.id === msg.tagId)) { 146 + state.tags.push({ id: msg.tagId, name: msg.tagName }); 147 + } 148 + render(); 149 + }, api.scopes.GLOBAL); 150 + 107 151 // Search input 108 152 searchInput.addEventListener('input', (e) => { 109 153 state.searchQuery = e.target.value;