experiments in a post-browser web
10
fork

Configure Feed

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

fix: group view buttons, kagi stale search, tags widget URL matching, border on tag removal

+416 -64
+22 -3
app/page/page.js
··· 2279 2279 let currentPageTags = []; 2280 2280 let allTagsCache = []; // For autocomplete suggestions 2281 2281 2282 + // Normalize URL for comparison — mirrors backend normalizeUrl() in datastore.ts 2283 + function normalizeUrlForCompare(u) { 2284 + try { 2285 + const parsed = new URL(u); 2286 + if (parsed.pathname !== '/' && parsed.pathname.endsWith('/')) { 2287 + parsed.pathname = parsed.pathname.slice(0, -1); 2288 + } 2289 + if ((parsed.protocol === 'http:' && parsed.port === '80') || (parsed.protocol === 'https:' && parsed.port === '443')) { 2290 + parsed.port = ''; 2291 + } 2292 + if (parsed.search) { 2293 + const params = new URLSearchParams(parsed.search); 2294 + const sortedParams = new URLSearchParams([...params.entries()].sort()); 2295 + parsed.search = sortedParams.toString(); 2296 + } 2297 + return parsed.toString(); 2298 + } catch { return u; } 2299 + } 2300 + 2282 2301 async function resolveItemId(url) { 2283 2302 if (!url || (!url.startsWith('http://') && !url.startsWith('https://'))) return null; 2284 2303 try { 2285 - // queryItems with exact URL search to find the item 2286 2304 const result = await api.datastore.queryItems({ type: 'url', search: url }); 2287 2305 if (result.success && result.data && result.data.length > 0) { 2288 - // Find exact match by content 2289 - const exact = result.data.find(item => item.content === url); 2306 + // Find exact match by normalized content (backend stores normalized URLs) 2307 + const normalUrl = normalizeUrlForCompare(url); 2308 + const exact = result.data.find(item => normalizeUrlForCompare(item.content) === normalUrl); 2290 2309 if (exact) return exact.id; 2291 2310 // Fallback: first result 2292 2311 return result.data[0].id;
+190 -60
backend/electron/group-mode.test.ts
··· 323 323 }); 324 324 }); 325 325 326 + // ============================================================================ 327 + // normalizeUrlForCompare (shared with page-navigation, extracted from page.js) 328 + // ============================================================================ 329 + 330 + function normalizeUrlForCompare(u: string): string { 331 + try { 332 + const parsed = new URL(u); 333 + if (parsed.pathname !== '/' && parsed.pathname.endsWith('/')) { 334 + parsed.pathname = parsed.pathname.slice(0, -1); 335 + } 336 + if ((parsed.protocol === 'http:' && parsed.port === '80') || (parsed.protocol === 'https:' && parsed.port === '443')) { 337 + parsed.port = ''; 338 + } 339 + if (parsed.search) { 340 + const params = new URLSearchParams(parsed.search); 341 + const sortedParams = new URLSearchParams([...params.entries()].sort()); 342 + parsed.search = sortedParams.toString(); 343 + } 344 + return parsed.toString(); 345 + } catch { return u; } 346 + } 347 + 348 + // ============================================================================ 349 + // shouldResetGroupOnTagRemoval (extracted from tag:item-removed handler) 350 + // ============================================================================ 351 + // When a tag is removed from an item, check if the window showing that item 352 + // in that group should have its group mode reset. 353 + 354 + function shouldResetGroupOnTagRemoval( 355 + removedTagId: string, 356 + removedItemUrl: string | null, 357 + windowGroupId: string, 358 + windowUrl: string | null, 359 + ): boolean { 360 + // Tag must match the window's group 361 + if (removedTagId !== windowGroupId) return false; 362 + // Both URLs must be present 363 + if (!removedItemUrl || !windowUrl) return false; 364 + // URLs must match after normalization 365 + return normalizeUrlForCompare(removedItemUrl) === normalizeUrlForCompare(windowUrl); 366 + } 367 + 368 + describe('shouldResetGroupOnTagRemoval', () => { 369 + it('returns true when tag matches groupId AND normalized URLs match', () => { 370 + assert.strictEqual( 371 + shouldResetGroupOnTagRemoval('tag-123', 'https://example.com/page', 'tag-123', 'https://example.com/page'), 372 + true 373 + ); 374 + }); 375 + 376 + it('returns false when tag matches but URLs differ', () => { 377 + assert.strictEqual( 378 + shouldResetGroupOnTagRemoval('tag-123', 'https://example.com/a', 'tag-123', 'https://example.com/b'), 379 + false 380 + ); 381 + }); 382 + 383 + it('returns false when tag does not match groupId', () => { 384 + assert.strictEqual( 385 + shouldResetGroupOnTagRemoval('tag-999', 'https://example.com/page', 'tag-123', 'https://example.com/page'), 386 + false 387 + ); 388 + }); 389 + 390 + it('returns true when URLs match with trailing slash difference', () => { 391 + assert.strictEqual( 392 + shouldResetGroupOnTagRemoval('tag-123', 'https://example.com/docs/', 'tag-123', 'https://example.com/docs'), 393 + true 394 + ); 395 + }); 396 + 397 + it('returns true when URLs match with query param order difference', () => { 398 + assert.strictEqual( 399 + shouldResetGroupOnTagRemoval('tag-123', 'https://example.com/page?b=2&a=1', 'tag-123', 'https://example.com/page?a=1&b=2'), 400 + true 401 + ); 402 + }); 403 + 404 + it('returns false when removedItemUrl is null', () => { 405 + assert.strictEqual( 406 + shouldResetGroupOnTagRemoval('tag-123', null, 'tag-123', 'https://example.com/page'), 407 + false 408 + ); 409 + }); 410 + 411 + it('returns false when removedItemUrl is empty string', () => { 412 + // Empty string fails URL parse, normalizeUrlForCompare returns it as-is 413 + assert.strictEqual( 414 + shouldResetGroupOnTagRemoval('tag-123', '', 'tag-123', 'https://example.com/page'), 415 + false 416 + ); 417 + }); 418 + 419 + it('returns false when windowUrl is null', () => { 420 + assert.strictEqual( 421 + shouldResetGroupOnTagRemoval('tag-123', 'https://example.com/page', 'tag-123', null), 422 + false 423 + ); 424 + }); 425 + 426 + it('returns false when windowUrl is empty string', () => { 427 + assert.strictEqual( 428 + shouldResetGroupOnTagRemoval('tag-123', 'https://example.com/page', 'tag-123', ''), 429 + false 430 + ); 431 + }); 432 + 433 + it('returns true with combined normalization: trailing slash + default port + param sort', () => { 434 + assert.strictEqual( 435 + shouldResetGroupOnTagRemoval( 436 + 'tag-123', 437 + 'https://example.com:443/docs/?z=1&a=2', 438 + 'tag-123', 439 + 'https://example.com/docs?a=2&z=1' 440 + ), 441 + true 442 + ); 443 + }); 444 + }); 445 + 446 + describe('shouldResetGroupOnTagRemoval — integration with shouldShowGroupBorder', () => { 447 + // After a reset, the window's mode changes from 'group' to 'default', 448 + // so shouldShowGroupBorder should return { show: false }. 449 + // We inline shouldShowGroupBorder here for the integration test. 450 + 451 + function shouldShowGroupBorderInline(context: { 452 + focusedWindowId: number | null; 453 + focusedWindowDestroyed: boolean; 454 + focusedWindowMode: string | null; 455 + focusedWindowMetadata: Record<string, unknown> | null; 456 + }): { show: boolean; color?: string; name?: string } { 457 + if (!context.focusedWindowId) return { show: false }; 458 + if (context.focusedWindowDestroyed) return { show: false }; 459 + if ( 460 + context.focusedWindowMode === 'group' && 461 + context.focusedWindowMetadata?.groupId 462 + ) { 463 + const rawColor = context.focusedWindowMetadata.color as string | undefined; 464 + const groupId = context.focusedWindowMetadata.groupId as string | undefined; 465 + const color = resolveGroupBorderColor(rawColor, groupId); 466 + const name = (context.focusedWindowMetadata.groupName as string) || 'group'; 467 + return { show: true, color, name }; 468 + } 469 + return { show: false }; 470 + } 471 + 472 + it('border hides after group mode reset triggered by tag removal', () => { 473 + // Before: window is in group mode, border is visible 474 + const beforeReset = shouldShowGroupBorderInline({ 475 + focusedWindowId: 1, 476 + focusedWindowDestroyed: false, 477 + focusedWindowMode: 'group', 478 + focusedWindowMetadata: { groupId: 'tag-123', groupName: 'Research', color: '#ff3b30' }, 479 + }); 480 + assert.strictEqual(beforeReset.show, true); 481 + 482 + // Tag removal triggers reset 483 + const shouldReset = shouldResetGroupOnTagRemoval( 484 + 'tag-123', 'https://example.com/page', 'tag-123', 'https://example.com/page' 485 + ); 486 + assert.strictEqual(shouldReset, true); 487 + 488 + // After reset: window mode changes to 'default', border should hide 489 + const afterReset = shouldShowGroupBorderInline({ 490 + focusedWindowId: 1, 491 + focusedWindowDestroyed: false, 492 + focusedWindowMode: 'default', 493 + focusedWindowMetadata: {}, 494 + }); 495 + assert.strictEqual(afterReset.show, false); 496 + }); 497 + 498 + it('border stays visible when tag removal does not match window', () => { 499 + const shouldReset = shouldResetGroupOnTagRemoval( 500 + 'tag-999', 'https://other.com/', 'tag-123', 'https://example.com/page' 501 + ); 502 + assert.strictEqual(shouldReset, false); 503 + 504 + // Window stays in group mode, border still shows 505 + const result = shouldShowGroupBorderInline({ 506 + focusedWindowId: 1, 507 + focusedWindowDestroyed: false, 508 + focusedWindowMode: 'group', 509 + focusedWindowMetadata: { groupId: 'tag-123', groupName: 'Research', color: '#ff3b30' }, 510 + }); 511 + assert.strictEqual(result.show, true); 512 + }); 513 + }); 514 + 326 515 describe('group screen border visibility (focus-based)', () => { 327 516 // Tests the focus-based border visibility logic. 328 517 // The border is a per-focused-window indicator: ··· 339 528 focusedWindowDestroyed: boolean; 340 529 focusedWindowMode: string | null; 341 530 focusedWindowMetadata: Record<string, unknown> | null; 342 - isModal?: boolean; 343 - }): { show: boolean; unchanged?: boolean; color?: string; name?: string } { 531 + }): { show: boolean; color?: string; name?: string } { 344 532 // No focused window → hide 345 533 if (!context.focusedWindowId) { 346 534 return { show: false }; ··· 348 536 // Focused window destroyed → hide 349 537 if (context.focusedWindowDestroyed) { 350 538 return { show: false }; 351 - } 352 - // Modal/transient windows (cmd palette, etc.) are "transparent" to border state — 353 - // focusing a modal does not change the border, preventing constant flicker during group work 354 - if (context.isModal) { 355 - return { show: false, unchanged: true }; 356 539 } 357 540 // Focused window in group mode with valid groupId → show 358 541 if ( ··· 555 738 }); 556 739 assert.strictEqual(r2.show, true); 557 740 assert.strictEqual(r2.name, 'Project'); 558 - }); 559 - 560 - // Modal window exemption — cmd palette and other modals are "transparent" to border state 561 - it('modal window (cmd palette) does not change border state', () => { 562 - const result = shouldShowGroupBorder({ 563 - focusedWindowId: 99, 564 - focusedWindowDestroyed: false, 565 - focusedWindowMode: 'default', 566 - focusedWindowMetadata: {}, 567 - isModal: true, 568 - }); 569 - assert.strictEqual(result.unchanged, true); 570 - }); 571 - 572 - it('modal window in group mode still treated as transparent', () => { 573 - const result = shouldShowGroupBorder({ 574 - focusedWindowId: 99, 575 - focusedWindowDestroyed: false, 576 - focusedWindowMode: 'group', 577 - focusedWindowMetadata: { groupId: 'tag-123' }, 578 - isModal: true, 579 - }); 580 - assert.strictEqual(result.unchanged, true); 581 - }); 582 - 583 - it('border stays visible through group → modal → group focus sequence', () => { 584 - // Group window focused → show 585 - const r1 = shouldShowGroupBorder({ 586 - focusedWindowId: 1, 587 - focusedWindowDestroyed: false, 588 - focusedWindowMode: 'group', 589 - focusedWindowMetadata: { groupId: 'tag-123', groupName: 'Work', color: '#ff3b30' }, 590 - }); 591 - assert.strictEqual(r1.show, true); 592 - 593 - // Cmd palette opens → unchanged (border stays visible) 594 - const r2 = shouldShowGroupBorder({ 595 - focusedWindowId: 99, 596 - focusedWindowDestroyed: false, 597 - focusedWindowMode: 'default', 598 - focusedWindowMetadata: {}, 599 - isModal: true, 600 - }); 601 - assert.strictEqual(r2.unchanged, true); 602 - 603 - // Cmd palette closes, group window regains focus → show 604 - const r3 = shouldShowGroupBorder({ 605 - focusedWindowId: 1, 606 - focusedWindowDestroyed: false, 607 - focusedWindowMode: 'group', 608 - focusedWindowMetadata: { groupId: 'tag-123', groupName: 'Work', color: '#ff3b30' }, 609 - }); 610 - assert.strictEqual(r3.show, true); 611 741 }); 612 742 613 743 it('switching between two different groups updates color and name', () => {
+52
backend/electron/ipc.ts
··· 2500 2500 existingByUrl.window.show(); 2501 2501 existingByUrl.window.focus(); 2502 2502 } 2503 + // Reload the page so content is fresh (the webview may have navigated 2504 + // away or the content may be stale from session restore). 2505 + try { 2506 + publish('system', PubSubScopes.GLOBAL, 'page:reload', { windowId: existingByUrl.id }); 2507 + } catch (e) { 2508 + DEBUG && console.log('Failed to send reload to reused window:', e); 2509 + } 2503 2510 return { success: true, id: existingByUrl.id, reused: true }; 2504 2511 } 2505 2512 } ··· 5049 5056 } else { 5050 5057 updateGroupScreenBorder(); 5051 5058 } 5059 + }); 5060 + 5061 + // Reset group mode when the group tag is removed from a page 5062 + subscribe(getSystemAddress(), PubSubScopes.GLOBAL, 'tag:item-removed', (msg: unknown) => { 5063 + const data = msg as { tagId: string; itemId: string }; 5064 + if (!data.tagId || !data.itemId) return; 5065 + 5066 + // Find all windows in group mode for this specific tag 5067 + const groupWindowIds = getWindowsMatchingContext('mode', (entry) => { 5068 + return entry.value === 'group' && entry.metadata?.groupId === data.tagId; 5069 + }); 5070 + if (groupWindowIds.length === 0) return; 5071 + 5072 + // Get the item to find its URL 5073 + const item = getItem(data.itemId); 5074 + if (!item) return; 5075 + const itemUrl = item.type === 'url' && item.content ? normalizeUrl(item.content) : null; 5076 + if (!itemUrl) return; 5077 + 5078 + // Check each group window — if its URL matches the removed item, reset to page mode 5079 + for (const windowId of groupWindowIds) { 5080 + const entry = getContextEntry('mode', windowId); 5081 + const windowUrl = entry?.metadata?.url as string | undefined; 5082 + if (windowUrl && normalizeUrl(windowUrl) === itemUrl) { 5083 + const result = addContextEntry('mode', 'page', { 5084 + windowId, 5085 + source: 'tag-removed', 5086 + metadata: { url: windowUrl } 5087 + }); 5088 + publish(getSystemAddress(), PubSubScopes.GLOBAL, 'context:changed', { 5089 + key: 'mode', 5090 + value: 'page', 5091 + metadata: { url: windowUrl }, 5092 + windowId, 5093 + source: 'tag-removed', 5094 + entryId: result.id 5095 + }); 5096 + publish(getSystemAddress(), PubSubScopes.GLOBAL, 'modes:changed', { 5097 + windowId, 5098 + major: 'page', 5099 + }); 5100 + DEBUG && console.log('[modes] Reset window', windowId, 'from group to page after tag removal'); 5101 + } 5102 + } 5103 + updateGroupScreenBorder(); 5052 5104 }); 5053 5105 5054 5106 // Mode definitions
+149
backend/electron/page-navigation.test.ts
··· 222 222 }); 223 223 }); 224 224 225 + // ============================================================================ 226 + // normalizeUrlForCompare (extracted from app/page/page.js resolveItemId) 227 + // ============================================================================ 228 + 229 + function normalizeUrlForCompare(u: string): string { 230 + try { 231 + const parsed = new URL(u); 232 + if (parsed.pathname !== '/' && parsed.pathname.endsWith('/')) { 233 + parsed.pathname = parsed.pathname.slice(0, -1); 234 + } 235 + if ((parsed.protocol === 'http:' && parsed.port === '80') || (parsed.protocol === 'https:' && parsed.port === '443')) { 236 + parsed.port = ''; 237 + } 238 + if (parsed.search) { 239 + const params = new URLSearchParams(parsed.search); 240 + const sortedParams = new URLSearchParams([...params.entries()].sort()); 241 + parsed.search = sortedParams.toString(); 242 + } 243 + return parsed.toString(); 244 + } catch { return u; } 245 + } 246 + 247 + describe('normalizeUrlForCompare — trailing slash handling', () => { 248 + it('removes trailing slash from paths', () => { 249 + assert.strictEqual( 250 + normalizeUrlForCompare('https://example.com/path/'), 251 + 'https://example.com/path' 252 + ); 253 + }); 254 + 255 + it('preserves root slash', () => { 256 + assert.strictEqual( 257 + normalizeUrlForCompare('https://example.com/'), 258 + 'https://example.com/' 259 + ); 260 + }); 261 + 262 + it('does not add trailing slash to bare domain', () => { 263 + // URL constructor normalizes bare domain to have trailing slash 264 + const result = normalizeUrlForCompare('https://example.com'); 265 + assert.strictEqual(result, 'https://example.com/'); 266 + }); 267 + }); 268 + 269 + describe('normalizeUrlForCompare — port normalization', () => { 270 + it('removes default port 443 for HTTPS', () => { 271 + assert.strictEqual( 272 + normalizeUrlForCompare('https://example.com:443/path'), 273 + 'https://example.com/path' 274 + ); 275 + }); 276 + 277 + it('removes default port 80 for HTTP', () => { 278 + assert.strictEqual( 279 + normalizeUrlForCompare('http://example.com:80/path'), 280 + 'http://example.com/path' 281 + ); 282 + }); 283 + 284 + it('preserves non-default port', () => { 285 + assert.strictEqual( 286 + normalizeUrlForCompare('https://example.com:8080/path'), 287 + 'https://example.com:8080/path' 288 + ); 289 + }); 290 + 291 + it('preserves port 80 on HTTPS (non-default)', () => { 292 + assert.strictEqual( 293 + normalizeUrlForCompare('https://example.com:80/path'), 294 + 'https://example.com:80/path' 295 + ); 296 + }); 297 + 298 + it('preserves port 443 on HTTP (non-default)', () => { 299 + assert.strictEqual( 300 + normalizeUrlForCompare('http://example.com:443/path'), 301 + 'http://example.com:443/path' 302 + ); 303 + }); 304 + }); 305 + 306 + describe('normalizeUrlForCompare — query param sorting', () => { 307 + it('sorts query params alphabetically', () => { 308 + assert.strictEqual( 309 + normalizeUrlForCompare('https://example.com/path?z=1&a=2&m=3'), 310 + 'https://example.com/path?a=2&m=3&z=1' 311 + ); 312 + }); 313 + 314 + it('leaves already-sorted params unchanged', () => { 315 + assert.strictEqual( 316 + normalizeUrlForCompare('https://example.com/path?a=1&b=2&c=3'), 317 + 'https://example.com/path?a=1&b=2&c=3' 318 + ); 319 + }); 320 + 321 + it('handles single query param', () => { 322 + assert.strictEqual( 323 + normalizeUrlForCompare('https://example.com/path?key=val'), 324 + 'https://example.com/path?key=val' 325 + ); 326 + }); 327 + }); 328 + 329 + describe('normalizeUrlForCompare — already normalized and invalid', () => { 330 + it('already normalized URL is unchanged', () => { 331 + const url = 'https://example.com/path?a=1&b=2'; 332 + assert.strictEqual(normalizeUrlForCompare(url), url); 333 + }); 334 + 335 + it('invalid URL returns original string', () => { 336 + assert.strictEqual(normalizeUrlForCompare('not-a-url'), 'not-a-url'); 337 + assert.strictEqual(normalizeUrlForCompare(''), ''); 338 + assert.strictEqual(normalizeUrlForCompare('://broken'), '://broken'); 339 + }); 340 + }); 341 + 342 + describe('normalizeUrlForCompare — URL matching scenarios for resolveItemId', () => { 343 + it('trailing slash mismatch resolves to same normalized URL', () => { 344 + const a = normalizeUrlForCompare('https://example.com/docs/'); 345 + const b = normalizeUrlForCompare('https://example.com/docs'); 346 + assert.strictEqual(a, b); 347 + }); 348 + 349 + it('query param order mismatch resolves to same normalized URL', () => { 350 + const a = normalizeUrlForCompare('https://example.com/page?b=2&a=1'); 351 + const b = normalizeUrlForCompare('https://example.com/page?a=1&b=2'); 352 + assert.strictEqual(a, b); 353 + }); 354 + 355 + it('different URLs do NOT match after normalization', () => { 356 + const a = normalizeUrlForCompare('https://example.com/path-a'); 357 + const b = normalizeUrlForCompare('https://example.com/path-b'); 358 + assert.notStrictEqual(a, b); 359 + }); 360 + 361 + it('different domains do NOT match', () => { 362 + const a = normalizeUrlForCompare('https://example.com/path'); 363 + const b = normalizeUrlForCompare('https://other.com/path'); 364 + assert.notStrictEqual(a, b); 365 + }); 366 + 367 + it('combined normalization: trailing slash + param sort + default port', () => { 368 + const a = normalizeUrlForCompare('https://example.com:443/docs/?z=1&a=2'); 369 + const b = normalizeUrlForCompare('https://example.com/docs?a=2&z=1'); 370 + assert.strictEqual(a, b); 371 + }); 372 + }); 373 + 225 374 describe('shouldHandleNavKey — all nav keys pass through in input contexts', () => { 226 375 const navKeys = ['j', 'k', 'g', 'G', 'ArrowDown', 'ArrowUp', 'Home', 'End']; 227 376
+3
features/groups/home.js
··· 1194 1194 try { 1195 1195 await api.datastore.deleteItem(item.id); 1196 1196 api.publish('item:deleted', { id: item.id }, api.scopes.GLOBAL); 1197 + if (state.currentTag) { await loadAddressesForTag(state.currentTag.id); renderAddresses(); } 1197 1198 } catch (err) { 1198 1199 console.error('[groups] Failed to delete item:', err); 1199 1200 } ··· 1202 1203 try { 1203 1204 await api.datastore.untagItem(item.id, tag.id); 1204 1205 api.publish('tag:item-removed', { itemId: item.id, tagId: tag.id }, api.scopes.GLOBAL); 1206 + if (state.currentTag) { await loadAddressesForTag(state.currentTag.id); renderAddresses(); } 1205 1207 } catch (err) { 1206 1208 console.error('[groups] Failed to remove tag:', err); 1207 1209 } ··· 1259 1261 try { 1260 1262 await api.datastore.untagItem(address.id, state.currentTag.id); 1261 1263 api.publish('tag:item-removed', { itemId: address.id, tagId: state.currentTag.id }, api.scopes.GLOBAL); 1264 + if (state.currentTag) { await loadAddressesForTag(state.currentTag.id); renderAddresses(); } 1262 1265 } catch (err) { 1263 1266 console.error('[groups] Failed to remove item from group:', err); 1264 1267 }
-1
features/websearch/background.js
··· 200 200 201 201 api.window.open(url, { 202 202 role: 'content', 203 - key: url, 204 203 width: 1024, 205 204 height: 768, 206 205 trackingSource: 'websearch',