experiments in a post-browser web
10
fork

Configure Feed

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

feat(search): add search view extension with text and tag search

+686 -4
+1 -1
backend/electron/main.ts
··· 74 74 75 75 // Built-in extensions that load in consolidated mode (iframes) 76 76 // External extensions (including 'example') load in separate windows 77 - const CONSOLIDATED_EXTENSION_IDS = ['cmd', 'editor', 'groups', 'hud', 'localsearch', 'peeks', 'slides', 'websearch', 'windows', 'page', 'files', 'pagestream', 'sheets', 'tags', 'feeds', 'helpdocs', 'entities', 'scripts']; 77 + const CONSOLIDATED_EXTENSION_IDS = ['cmd', 'editor', 'groups', 'hud', 'localsearch', 'peeks', 'search', 'slides', 'websearch', 'windows', 'page', 'files', 'pagestream', 'sheets', 'tags', 'feeds', 'helpdocs', 'entities', 'scripts']; 78 78 79 79 // Extensions that must load eagerly (not lazy) — needed at startup 80 80 const EAGER_EXTENSION_IDS = new Set(['cmd', 'hud']);
+24 -3
extensions/cmd/panel.js
··· 752 752 state.typed = ''; 753 753 updateCommandUI(); 754 754 updateResultsUI(); 755 + } else if (trimmedText) { 756 + // No matching command and not a URL — open search view with the typed text 757 + log('cmd:panel', 'No command match, opening search for:', trimmedText); 758 + 759 + api.window.open(`peek://ext/search/home.html?q=${encodeURIComponent(trimmedText)}`, { 760 + role: 'workspace', 761 + key: 'search-home', 762 + width: 700, 763 + height: 600, 764 + trackingSource: 'cmd', 765 + trackingSourceId: 'search' 766 + }).catch(error => { 767 + log.error('cmd:panel', 'Failed to open search:', error); 768 + }); 769 + 770 + // Clear input and UI 771 + commandInput.value = ''; 772 + state.typed = ''; 773 + updateCommandUI(); 774 + updateResultsUI(); 775 + 776 + // Close panel after opening search 777 + setTimeout(shutdown, 100); 755 778 } else { 756 - // No matching command and not a URL — do nothing. 757 - // User must Tab to accept ghost suggestion or type the full command name. 758 - log('cmd:panel', 'No committed command match, ignoring Enter for:', trimmedText); 779 + log('cmd:panel', 'Empty input, ignoring Enter'); 759 780 } 760 781 return; 761 782 }
+132
extensions/search/home.css
··· 1 + /* Import theme variables */ 2 + @import url('peek://theme/variables.css'); 3 + 4 + * { 5 + box-sizing: border-box; 6 + margin: 0; 7 + padding: 0; 8 + } 9 + 10 + html { 11 + font-family: var(--theme-font-sans); 12 + -webkit-font-smoothing: antialiased; 13 + font-size: 14px; 14 + line-height: 1.5; 15 + } 16 + 17 + body { 18 + background: var(--base00); 19 + color: var(--base05); 20 + min-height: 100vh; 21 + } 22 + 23 + /* Search header */ 24 + .search-header { 25 + padding: 12px 16px 0 16px; 26 + } 27 + 28 + .search-title { 29 + font-size: 15px; 30 + font-weight: 600; 31 + color: var(--base05); 32 + margin: 0; 33 + white-space: nowrap; 34 + overflow: hidden; 35 + text-overflow: ellipsis; 36 + } 37 + 38 + .search-title .tag-token { 39 + color: var(--base0D); 40 + } 41 + 42 + .search-title .text-token { 43 + color: var(--base05); 44 + } 45 + 46 + /* Grid toolbar */ 47 + peek-grid-toolbar.grid-toolbar { 48 + padding: 0 16px; 49 + --theme-bg-secondary: var(--base01); 50 + --theme-border: var(--base02); 51 + --theme-text: var(--base05); 52 + --theme-text-muted: var(--base04); 53 + --theme-bg-tertiary: var(--base02); 54 + --theme-accent: var(--base0D); 55 + } 56 + 57 + /* Cards container - peek-grid handles layout */ 58 + .cards { 59 + padding: 12px 16px; 60 + } 61 + 62 + /* Component customization for peek-card - shared across all cards */ 63 + peek-card { 64 + --peek-card-bg: var(--base01); 65 + --peek-card-hover-bg: var(--base02); 66 + --peek-card-border: transparent; 67 + --peek-card-radius: 6px; 68 + --peek-card-padding: 8px; 69 + --peek-card-gap: 4px; 70 + max-width: 260px; 71 + } 72 + 73 + peek-card[selected] { 74 + --peek-card-bg: var(--base02); 75 + } 76 + 77 + peek-card[selected]:hover { 78 + --peek-card-bg: var(--base03); 79 + } 80 + 81 + /* Result card slotted content */ 82 + .result-card .card-favicon { 83 + width: 12px; 84 + height: 12px; 85 + border-radius: 3px; 86 + flex-shrink: 0; 87 + background: var(--base02); 88 + object-fit: contain; 89 + margin-top: 2px; 90 + } 91 + 92 + .result-card .card-type-icon { 93 + width: 12px; 94 + height: 12px; 95 + flex-shrink: 0; 96 + margin-top: 2px; 97 + color: var(--base04); 98 + } 99 + 100 + /* Shared slotted content styles */ 101 + .card-title { 102 + font-size: 13px; 103 + font-weight: 600; 104 + color: var(--base05); 105 + white-space: nowrap; 106 + overflow: hidden; 107 + text-overflow: ellipsis; 108 + } 109 + 110 + .card-meta { 111 + font-size: 11px; 112 + color: var(--base03); 113 + } 114 + 115 + /* List view mode - remove max-width constraint */ 116 + peek-grid.cards[view-mode="list"] peek-card { 117 + max-width: none; 118 + } 119 + 120 + /* Masonry view - remove max-width constraint */ 121 + peek-grid.cards[view-mode="masonry"] peek-card { 122 + max-width: none; 123 + } 124 + 125 + /* Empty state */ 126 + .empty-state { 127 + grid-column: 1 / -1; 128 + text-align: center; 129 + padding: 32px 16px; 130 + color: var(--base03); 131 + font-size: 13px; 132 + }
+529
extensions/search/home.js
··· 1 + /** 2 + * Search View - displays search results filtered by text and tags 3 + * 4 + * Query syntax (from URL param `q`): 5 + * #tag — match items tagged with "tag" 6 + * text — match items whose content/title/url contains "text" 7 + * #a #b foo — items tagged "a" AND "b" with text matching "foo" 8 + */ 9 + 10 + const api = window.app; 11 + const debug = api.debug; 12 + 13 + // ── State ────────────────────────────────────────────────────────────── 14 + 15 + let state = { 16 + query: '', 17 + parsedTags: [], // tag name strings (without #) 18 + parsedText: '', // free-text portion 19 + results: [], 20 + selectedIndex: 0 21 + }; 22 + 23 + // View preferences 24 + let viewPrefs = { 25 + viewMode: 'list', 26 + sortBy: 'recent', 27 + sortDirection: 'desc' 28 + }; 29 + 30 + const SORT_OPTIONS = [ 31 + { value: 'name', label: 'Name' }, 32 + { value: 'count', label: 'Visits' }, 33 + { value: 'recent', label: 'Recent' } 34 + ]; 35 + 36 + // ── Helpers ──────────────────────────────────────────────────────────── 37 + 38 + /** 39 + * Check if a URL is a navigable web URL (http/https only) 40 + */ 41 + const isWebUrl = (url) => { 42 + if (!url) return false; 43 + return url.startsWith('http://') || url.startsWith('https://'); 44 + }; 45 + 46 + /** 47 + * Parse the search query into tags and text components 48 + * e.g. "#todo #today frogs" → { tags: ["todo", "today"], text: "frogs" } 49 + */ 50 + const parseQuery = (q) => { 51 + const tokens = (q || '').split(/\s+/).filter(Boolean); 52 + const tags = []; 53 + const textParts = []; 54 + 55 + for (const token of tokens) { 56 + if (token.startsWith('#') && token.length > 1) { 57 + tags.push(token.slice(1)); 58 + } else { 59 + textParts.push(token); 60 + } 61 + } 62 + 63 + return { 64 + tags, 65 + text: textParts.join(' ') 66 + }; 67 + }; 68 + 69 + /** 70 + * Render the search title with colored tokens 71 + */ 72 + const renderTitle = () => { 73 + const titleEl = document.querySelector('.search-title'); 74 + if (!titleEl) return; 75 + 76 + const parts = []; 77 + for (const tag of state.parsedTags) { 78 + parts.push(`<span class="tag-token">#${escapeHtml(tag)}</span>`); 79 + } 80 + if (state.parsedText) { 81 + parts.push(`<span class="text-token">${escapeHtml(state.parsedText)}</span>`); 82 + } 83 + 84 + titleEl.innerHTML = parts.length > 0 85 + ? `Search: ${parts.join(' ')}` 86 + : 'Search'; 87 + }; 88 + 89 + const escapeHtml = (str) => { 90 + const div = document.createElement('div'); 91 + div.textContent = str; 92 + return div.innerHTML; 93 + }; 94 + 95 + // ── Data fetching ────────────────────────────────────────────────────── 96 + 97 + /** 98 + * Resolve tag names to tag objects using frecency list 99 + */ 100 + const resolveTagNames = async (tagNames) => { 101 + const result = await api.datastore.getTagsByFrecency(); 102 + if (!result.success) return []; 103 + 104 + const allTags = result.data; 105 + const resolved = []; 106 + 107 + for (const name of tagNames) { 108 + const lower = name.toLowerCase(); 109 + const match = allTags.find(t => t.name.toLowerCase() === lower); 110 + if (match) { 111 + resolved.push(match); 112 + } 113 + } 114 + 115 + return resolved; 116 + }; 117 + 118 + /** 119 + * Get items that have ALL specified tags (intersection) 120 + */ 121 + const getItemsByAllTags = async (tags) => { 122 + if (tags.length === 0) return null; // null = no tag filter 123 + 124 + // Get items for first tag 125 + const firstResult = await api.datastore.getItemsByTag(tags[0].id); 126 + if (!firstResult.success) return []; 127 + 128 + let items = firstResult.data; 129 + 130 + // Intersect with items from remaining tags 131 + for (let i = 1; i < tags.length; i++) { 132 + const tagResult = await api.datastore.getItemsByTag(tags[i].id); 133 + if (!tagResult.success) return []; 134 + 135 + const tagItemIds = new Set(tagResult.data.map(item => item.id)); 136 + items = items.filter(item => tagItemIds.has(item.id)); 137 + } 138 + 139 + return items; 140 + }; 141 + 142 + /** 143 + * Run the full search: resolve tags, query items, filter by text 144 + */ 145 + const runSearch = async () => { 146 + const { tags: tagNames, text } = parseQuery(state.query); 147 + state.parsedTags = tagNames; 148 + state.parsedText = text; 149 + 150 + let items; 151 + 152 + if (tagNames.length > 0) { 153 + // Resolve tag names to tag objects 154 + const resolvedTags = await resolveTagNames(tagNames); 155 + 156 + if (resolvedTags.length < tagNames.length) { 157 + // Some tags not found — show empty results 158 + debug && console.log('[search] Some tags not found, showing empty results'); 159 + state.results = []; 160 + return; 161 + } 162 + 163 + // Get items matching ALL tags 164 + items = await getItemsByAllTags(resolvedTags); 165 + if (!items) items = []; 166 + } else if (text) { 167 + // Text-only search — use queryItems with search filter 168 + const result = await api.datastore.queryItems({ search: text }); 169 + items = result.success ? result.data : []; 170 + } else { 171 + // Empty query 172 + state.results = []; 173 + return; 174 + } 175 + 176 + // Apply text filter if both tags and text specified 177 + if (tagNames.length > 0 && text) { 178 + const lower = text.toLowerCase(); 179 + items = items.filter(item => { 180 + const content = (item.content || '').toLowerCase(); 181 + const title = (item.title || '').toLowerCase(); 182 + return content.includes(lower) || title.includes(lower); 183 + }); 184 + } 185 + 186 + state.results = items; 187 + debug && console.log('[search] Results:', items.length); 188 + }; 189 + 190 + // ── Sorting ──────────────────────────────────────────────────────────── 191 + 192 + const sortResults = (items) => { 193 + const dir = viewPrefs.sortDirection === 'asc' ? 1 : -1; 194 + return [...items].sort((a, b) => { 195 + switch (viewPrefs.sortBy) { 196 + case 'name': { 197 + const na = (a.title || a.content || '').toLowerCase(); 198 + const nb = (b.title || b.content || '').toLowerCase(); 199 + return dir * na.localeCompare(nb); 200 + } 201 + case 'count': { 202 + const ca = a.visitCount || 0; 203 + const cb = b.visitCount || 0; 204 + return dir * (ca - cb); 205 + } 206 + case 'recent': { 207 + const ra = a.updatedAt || a.createdAt || 0; 208 + const rb = b.updatedAt || b.createdAt || 0; 209 + return dir * (ra - rb); 210 + } 211 + default: 212 + return 0; 213 + } 214 + }); 215 + }; 216 + 217 + // ── Rendering ────────────────────────────────────────────────────────── 218 + 219 + const render = () => { 220 + const container = document.querySelector('.cards'); 221 + container.innerHTML = ''; 222 + 223 + renderTitle(); 224 + 225 + if (state.results.length === 0) { 226 + const message = state.query 227 + ? 'No items match your search.' 228 + : 'Enter a search query to find items.'; 229 + container.innerHTML = `<div class="empty-state">${message}</div>`; 230 + return; 231 + } 232 + 233 + const sorted = sortResults(state.results); 234 + 235 + sorted.forEach(item => { 236 + const card = createResultCard(item); 237 + container.appendChild(card); 238 + }); 239 + 240 + state.selectedIndex = 0; 241 + updateSelection(); 242 + }; 243 + 244 + /** 245 + * Create a card element for a search result item 246 + */ 247 + const createResultCard = (item) => { 248 + const card = document.createElement('peek-card'); 249 + card.className = 'result-card'; 250 + card.interactive = true; 251 + card.elevated = true; 252 + card.dataset.itemId = item.id; 253 + 254 + const itemUrl = item.content; 255 + const isUrl = item.type === 'url'; 256 + 257 + // Get display title 258 + let displayTitle = item.title; 259 + if (!displayTitle && item.metadata) { 260 + try { 261 + const meta = typeof item.metadata === 'string' ? JSON.parse(item.metadata) : item.metadata; 262 + displayTitle = meta.title; 263 + } catch (e) { 264 + // Ignore parse errors 265 + } 266 + } 267 + displayTitle = displayTitle || itemUrl || '(untitled)'; 268 + 269 + // Header slot: favicon/icon + title 270 + const header = document.createElement('div'); 271 + header.slot = 'header'; 272 + header.style.display = 'flex'; 273 + header.style.alignItems = 'center'; 274 + header.style.gap = '8px'; 275 + 276 + if (isUrl && isWebUrl(itemUrl)) { 277 + const favicon = document.createElement('img'); 278 + favicon.className = 'card-favicon'; 279 + favicon.src = item.favicon || 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">🌐</text></svg>'; 280 + favicon.onerror = () => { 281 + favicon.src = 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">🌐</text></svg>'; 282 + }; 283 + header.appendChild(favicon); 284 + } else { 285 + // Text/other item type icon 286 + const icon = document.createElement('span'); 287 + icon.className = 'card-type-icon'; 288 + icon.textContent = item.type === 'text' ? 'T' : '#'; 289 + icon.style.cssText = 'font-size:12px;font-weight:700;opacity:0.5;'; 290 + header.appendChild(icon); 291 + } 292 + 293 + const title = document.createElement('h2'); 294 + title.className = 'card-title'; 295 + title.textContent = displayTitle; 296 + title.style.margin = '0'; 297 + title.style.flex = '1'; 298 + title.style.minWidth = '0'; 299 + 300 + header.appendChild(title); 301 + card.appendChild(header); 302 + 303 + // Footer slot: URL/content preview + visit count 304 + const footer = document.createElement('div'); 305 + footer.slot = 'footer'; 306 + footer.className = 'card-meta'; 307 + footer.style.cssText = 'display:flex;justify-content:space-between;align-items:center;gap:8px;'; 308 + 309 + if (isUrl && itemUrl && displayTitle !== itemUrl) { 310 + const urlSpan = document.createElement('span'); 311 + urlSpan.className = 'card-url'; 312 + try { 313 + const u = new URL(itemUrl); 314 + urlSpan.textContent = u.hostname + (u.pathname !== '/' ? u.pathname : ''); 315 + } catch { 316 + urlSpan.textContent = itemUrl; 317 + } 318 + urlSpan.style.cssText = 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;opacity:0.7;'; 319 + footer.appendChild(urlSpan); 320 + } else if (!isUrl && itemUrl) { 321 + // Show content preview for non-URL items 322 + const preview = document.createElement('span'); 323 + preview.className = 'card-url'; 324 + preview.textContent = itemUrl.length > 60 ? itemUrl.slice(0, 60) + '...' : itemUrl; 325 + preview.style.cssText = 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;opacity:0.7;'; 326 + footer.appendChild(preview); 327 + } 328 + 329 + const visitCount = item.visitCount || 0; 330 + if (visitCount > 0) { 331 + const visitSpan = document.createElement('span'); 332 + visitSpan.textContent = `${visitCount} ${visitCount === 1 ? 'visit' : 'visits'}`; 333 + visitSpan.style.flexShrink = '0'; 334 + footer.appendChild(visitSpan); 335 + } 336 + 337 + card.appendChild(footer); 338 + 339 + // Click to open item 340 + let isOpening = false; 341 + card.addEventListener('card-click', async () => { 342 + if (isOpening) return; 343 + isOpening = true; 344 + try { 345 + if (isUrl && isWebUrl(itemUrl)) { 346 + // Open URL in a content window 347 + await api.window.open(itemUrl, { 348 + role: 'content', 349 + key: itemUrl, 350 + width: 800, 351 + height: 600 352 + }); 353 + } else if (item.type === 'text') { 354 + // Open text item in editor 355 + api.publish('editor:open', { itemId: item.id }, api.scopes.GLOBAL); 356 + } 357 + } finally { 358 + setTimeout(() => { isOpening = false; }, 500); 359 + } 360 + }); 361 + 362 + return card; 363 + }; 364 + 365 + // ── Keyboard navigation ──────────────────────────────────────────────── 366 + 367 + const getCards = () => { 368 + return Array.from(document.querySelectorAll('.cards peek-card')); 369 + }; 370 + 371 + const updateSelection = () => { 372 + const cards = getCards(); 373 + cards.forEach((card, i) => { 374 + card.selected = (i === state.selectedIndex); 375 + }); 376 + 377 + const selected = cards[state.selectedIndex]; 378 + if (selected) { 379 + selected.focus(); 380 + selected.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); 381 + } 382 + }; 383 + 384 + const activateSelected = () => { 385 + const cards = getCards(); 386 + const selected = cards[state.selectedIndex]; 387 + if (selected) { 388 + selected.click(); 389 + } 390 + }; 391 + 392 + const getGridColumns = (cards) => { 393 + if (cards.length < 2) return 1; 394 + const firstTop = cards[0].getBoundingClientRect().top; 395 + for (let i = 1; i < cards.length; i++) { 396 + if (cards[i].getBoundingClientRect().top !== firstTop) { 397 + return i; 398 + } 399 + } 400 + return cards.length; 401 + }; 402 + 403 + const handleKeydown = (e) => { 404 + const cards = getCards(); 405 + if (cards.length === 0) return; 406 + 407 + const cols = getGridColumns(cards); 408 + 409 + switch (e.key) { 410 + case 'j': 411 + case 'ArrowDown': 412 + e.preventDefault(); 413 + if (state.selectedIndex + cols < cards.length) { 414 + state.selectedIndex += cols; 415 + updateSelection(); 416 + } 417 + break; 418 + case 'k': 419 + case 'ArrowUp': 420 + e.preventDefault(); 421 + if (state.selectedIndex - cols >= 0) { 422 + state.selectedIndex -= cols; 423 + updateSelection(); 424 + } 425 + break; 426 + case 'h': 427 + case 'ArrowLeft': 428 + e.preventDefault(); 429 + if (state.selectedIndex > 0) { 430 + state.selectedIndex--; 431 + updateSelection(); 432 + } 433 + break; 434 + case 'l': 435 + case 'ArrowRight': 436 + e.preventDefault(); 437 + if (state.selectedIndex < cards.length - 1) { 438 + state.selectedIndex++; 439 + updateSelection(); 440 + } 441 + break; 442 + case 'Enter': 443 + e.preventDefault(); 444 + activateSelected(); 445 + break; 446 + case 'Escape': 447 + e.preventDefault(); 448 + window.close(); 449 + break; 450 + } 451 + }; 452 + 453 + // ── Toolbar ──────────────────────────────────────────────────────────── 454 + 455 + const loadViewPrefs = () => { 456 + try { 457 + const stored = localStorage.getItem('search:viewPrefs'); 458 + if (stored) { 459 + viewPrefs = { ...viewPrefs, ...JSON.parse(stored) }; 460 + } 461 + } catch (err) { 462 + debug && console.log('[search] Failed to load viewPrefs:', err); 463 + } 464 + }; 465 + 466 + const saveViewPrefs = () => { 467 + try { 468 + localStorage.setItem('search:viewPrefs', JSON.stringify(viewPrefs)); 469 + } catch (err) { 470 + debug && console.log('[search] Failed to save viewPrefs:', err); 471 + } 472 + }; 473 + 474 + const setupToolbar = () => { 475 + const toolbar = document.querySelector('.grid-toolbar'); 476 + const container = document.querySelector('.cards'); 477 + if (!toolbar) return; 478 + 479 + toolbar.sortOptions = SORT_OPTIONS; 480 + toolbar.sortBy = viewPrefs.sortBy; 481 + toolbar.sortDirection = viewPrefs.sortDirection; 482 + toolbar.viewMode = viewPrefs.viewMode; 483 + container.viewMode = viewPrefs.viewMode; 484 + 485 + toolbar.addEventListener('sort-change', (e) => { 486 + viewPrefs.sortBy = e.detail.sortBy; 487 + viewPrefs.sortDirection = e.detail.sortDirection; 488 + saveViewPrefs(); 489 + render(); 490 + }); 491 + 492 + toolbar.addEventListener('view-mode-change', (e) => { 493 + viewPrefs.viewMode = e.detail.mode; 494 + container.viewMode = e.detail.mode; 495 + saveViewPrefs(); 496 + render(); 497 + }); 498 + }; 499 + 500 + // ── Init ─────────────────────────────────────────────────────────────── 501 + 502 + const init = async () => { 503 + debug && console.log('[search] init'); 504 + 505 + // Parse query from URL 506 + const params = new URLSearchParams(window.location.search); 507 + state.query = params.get('q') || ''; 508 + 509 + // Set up toolbar and preferences 510 + loadViewPrefs(); 511 + setupToolbar(); 512 + 513 + // Keyboard navigation 514 + document.addEventListener('keydown', handleKeydown); 515 + 516 + // Register escape handler 517 + if (api.escape) { 518 + api.escape.onEscape(() => { 519 + window.close(); 520 + return { handled: true }; 521 + }); 522 + } 523 + 524 + // Run search and render 525 + await runSearch(); 526 + render(); 527 + }; 528 + 529 + document.addEventListener('DOMContentLoaded', init);