Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

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

feat: E2EE workspace search on landing page (#396)

scott f05c5746 8640a0ea

+225 -16
+26
src/css/app.css
··· 12378 12378 } 12379 12379 .content-search-meta { font-size: 0.7rem; color: var(--color-text-faint); } 12380 12380 12381 + .content-search-header--forge { 12382 + color: oklch(0.45 0.1 250); 12383 + font-weight: 600; 12384 + } 12385 + 12386 + .content-search-item--forge { 12387 + border-left: 3px solid oklch(0.65 0.1 250); 12388 + padding-left: 8px; 12389 + } 12390 + 12391 + .content-search-badge { 12392 + margin-left: 8px; 12393 + padding: 1px 6px; 12394 + border-radius: 3px; 12395 + font-size: 0.65rem; 12396 + font-weight: 600; 12397 + text-transform: uppercase; 12398 + letter-spacing: 0.04em; 12399 + vertical-align: 1px; 12400 + } 12401 + 12402 + .content-search-badge--forge { 12403 + background: oklch(0.92 0.04 250); 12404 + color: oklch(0.45 0.1 250); 12405 + } 12406 + 12381 12407 /* ── Batch Action Bar (landing multi-select) ──────────────────── */ 12382 12408 12383 12409 .batch-action-bar {
+64 -16
src/landing-content-search.ts
··· 4 4 * Since all docs are E2EE, content search must decrypt each document client-side. 5 5 * This module fetches snapshots, decrypts them, extracts text, and searches. 6 6 * Results are displayed below the document list with match snippets. 7 + * 8 + * Forge workspaces and reports are surfaced in a dedicated section above the 9 + * regular content matches so workspace history is findable by content, not 10 + * just by title. 7 11 */ 8 12 9 13 import { importKey, decrypt } from './lib/crypto.js'; ··· 17 21 keyStr: string; 18 22 snippet: string; 19 23 matchCount: number; 24 + /** True if the document is tagged as a Forge workspace or report. */ 25 + isForgeWorkspace: boolean; 26 + } 27 + 28 + /** 29 + * Check if a document's tag string marks it as a Forge workspace. 30 + * Exported for testing. 31 + */ 32 + export function hasForgeTag(tagsField: string | null | undefined): boolean { 33 + if (!tagsField) return false; 34 + try { 35 + const parsed = JSON.parse(tagsField); 36 + if (!Array.isArray(parsed)) return false; 37 + return parsed.includes('forge-workspace') || parsed.includes('forge-report'); 38 + } catch { 39 + return false; 40 + } 20 41 } 21 42 22 43 const MAX_CACHE_SIZE = 50; ··· 29 50 */ 30 51 export async function searchContent( 31 52 query: string, 32 - docs: Array<{ id: string; type: string; _decryptedName?: string; _keyStr?: string }>, 53 + docs: Array<{ id: string; type: string; tags?: string | null; _decryptedName?: string; _keyStr?: string }>, 33 54 ): Promise<ContentSearchResult[]> { 34 55 if (!query || query.length < 3) return []; 35 56 ··· 90 111 keyStr: doc._keyStr || '', 91 112 snippet, 92 113 matchCount: positions.length, 114 + isForgeWorkspace: hasForgeTag(doc.tags), 93 115 }); 94 116 } 95 117 96 - // Sort by match count descending 97 - results.sort((a, b) => b.matchCount - a.matchCount); 118 + // Sort Forge workspaces first (most meaningful context), then by match count 119 + results.sort((a, b) => { 120 + if (a.isForgeWorkspace !== b.isForgeWorkspace) { 121 + return a.isForgeWorkspace ? -1 : 1; 122 + } 123 + return b.matchCount - a.matchCount; 124 + }); 98 125 99 126 return results.slice(0, 20); // Cap at 20 results 100 127 } ··· 116 143 container.style.display = ''; 117 144 const queryLower = query.toLowerCase(); 118 145 119 - let html = `<div class="content-search-header">Content matches <span class="content-search-count">${results.length}</span></div>`; 120 - html += '<div class="content-search-list">'; 146 + const forgeResults = results.filter(r => r.isForgeWorkspace); 147 + const regularResults = results.filter(r => !r.isForgeWorkspace); 121 148 122 - for (const r of results) { 123 - const path = docPath(r.docType); 124 - const href = r.keyStr ? `${path}/${r.docId}#${r.keyStr}` : '#'; 125 - // Highlight the match in the snippet 126 - const snippetHtml = highlightSnippet(r.snippet, queryLower); 149 + let html = ''; 127 150 128 - html += `<a class="content-search-item" href="${href}">`; 129 - html += `<span class="content-search-name">${escapeHtml(r.docName)}</span>`; 130 - html += `<span class="content-search-snippet">${snippetHtml}</span>`; 131 - html += `<span class="content-search-meta">${r.matchCount} match${r.matchCount !== 1 ? 'es' : ''}</span>`; 132 - html += '</a>'; 151 + if (forgeResults.length > 0) { 152 + html += `<div class="content-search-header content-search-header--forge">\u2692 Forge workspaces <span class="content-search-count">${forgeResults.length}</span></div>`; 153 + html += '<div class="content-search-list">'; 154 + for (const r of forgeResults) { 155 + html += renderSearchItem(r, queryLower); 156 + } 157 + html += '</div>'; 133 158 } 134 159 135 - html += '</div>'; 160 + if (regularResults.length > 0) { 161 + html += `<div class="content-search-header">Content matches <span class="content-search-count">${regularResults.length}</span></div>`; 162 + html += '<div class="content-search-list">'; 163 + for (const r of regularResults) { 164 + html += renderSearchItem(r, queryLower); 165 + } 166 + html += '</div>'; 167 + } 168 + 136 169 container.innerHTML = html; 170 + } 171 + 172 + function renderSearchItem(r: ContentSearchResult, queryLower: string): string { 173 + const path = docPath(r.docType); 174 + const href = r.keyStr ? `${path}/${r.docId}#${r.keyStr}` : '#'; 175 + const snippetHtml = highlightSnippet(r.snippet, queryLower); 176 + const badge = r.isForgeWorkspace 177 + ? '<span class="content-search-badge content-search-badge--forge">forge</span>' 178 + : ''; 179 + 180 + return `<a class="content-search-item${r.isForgeWorkspace ? ' content-search-item--forge' : ''}" href="${href}">` + 181 + `<span class="content-search-name">${escapeHtml(r.docName)}${badge}</span>` + 182 + `<span class="content-search-snippet">${snippetHtml}</span>` + 183 + `<span class="content-search-meta">${r.matchCount} match${r.matchCount !== 1 ? 'es' : ''}</span>` + 184 + '</a>'; 137 185 } 138 186 139 187 function highlightSnippet(snippet: string, queryLower: string): string {
+25
src/landing-render.ts
··· 27 27 export function docPath(type: string): string { return DOC_PATH_MAP[type] || '/sheets'; } 28 28 export function docIcon(type: string): string { return DOC_ICON_MAP[type] || '&#9638;'; } 29 29 30 + /** 31 + * Returns true when a document is a Forge workspace or report. These docs 32 + * get their own dedicated section on the landing page and should be 33 + * filtered out of the main doc feed so they don't crowd user content. 34 + * 35 + * Exported as a pure helper for tests and reuse by other filter callers. 36 + */ 37 + export function isForgeDoc(doc: { tags: string | null }): boolean { 38 + const tags = parseTags(doc.tags); 39 + return tags.includes('forge-workspace') || tags.includes('forge-report'); 40 + } 41 + 30 42 export function escapeHtml(text: string): string { 31 43 const div = document.createElement('div'); 32 44 div.textContent = text; ··· 446 458 447 459 // Apply type filter 448 460 visibleDocs = filterByType(visibleDocs, activeTypeFilter); 461 + 462 + // Hide Forge workspaces from the main feed. They have their own dedicated 463 + // section rendered by renderForgeWorkspaces. Exceptions where the user has 464 + // explicitly asked to see them: 465 + // - a forge-* tag is the active tag filter 466 + // - the doc lives in a user-created folder (explicit placement) 467 + // - a search is active (user wants to find-anything) 468 + const forgeTagFilterActive = 469 + activeTagFilter === 'forge-workspace' || activeTagFilter === 'forge-report' || activeTagFilter === 'forge-done'; 470 + const hideForge = !forgeTagFilterActive && currentFolderId === null && !searchQuery; 471 + if (hideForge) { 472 + visibleDocs = visibleDocs.filter(d => !isForgeDoc(d)); 473 + } 449 474 450 475 // Render tag filter bar 451 476 renderTagFilter(deps, active);
+49
tests/landing-forge-hide.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + 3 + /** 4 + * Tests for `isForgeDoc` — the predicate that keeps Forge workspaces out 5 + * of the main landing page feed. 6 + * 7 + * The predicate reads the same tag convention as the Forge section, so 8 + * a doc tagged forge-workspace or forge-report should return true. 9 + */ 10 + 11 + import { isForgeDoc } from '../src/landing-render.js'; 12 + 13 + describe('isForgeDoc', () => { 14 + it('matches forge-workspace tag', () => { 15 + expect(isForgeDoc({ tags: '["forge-workspace"]' })).toBe(true); 16 + }); 17 + 18 + it('matches forge-report tag', () => { 19 + expect(isForgeDoc({ tags: '["forge-report"]' })).toBe(true); 20 + }); 21 + 22 + it('matches when forge-done is set alongside forge-workspace', () => { 23 + expect(isForgeDoc({ tags: '["forge-workspace","forge-done"]' })).toBe(true); 24 + }); 25 + 26 + it('matches when forge tag is mixed with user tags', () => { 27 + expect(isForgeDoc({ tags: '["pinned","forge-workspace"]' })).toBe(true); 28 + }); 29 + 30 + it('does not match unrelated tags', () => { 31 + expect(isForgeDoc({ tags: '["pinned","shared"]' })).toBe(false); 32 + }); 33 + 34 + it('does not match forge-done alone (archived workspace still has forge-workspace in practice)', () => { 35 + expect(isForgeDoc({ tags: '["forge-done"]' })).toBe(false); 36 + }); 37 + 38 + it('handles null tags', () => { 39 + expect(isForgeDoc({ tags: null })).toBe(false); 40 + }); 41 + 42 + it('handles empty string tags', () => { 43 + expect(isForgeDoc({ tags: '' })).toBe(false); 44 + }); 45 + 46 + it('handles empty array', () => { 47 + expect(isForgeDoc({ tags: '[]' })).toBe(false); 48 + }); 49 + });
+61
tests/landing-workspace-search.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + 3 + /** 4 + * Unit tests for the Forge workspace search helpers. 5 + * 6 + * Covers the tag detection used to decide whether a content-search hit 7 + * should be surfaced as a Forge workspace result. The full search flow 8 + * is not exercised here — it needs decrypted snapshots and the network. 9 + */ 10 + 11 + import { hasForgeTag } from '../src/landing-content-search.js'; 12 + 13 + describe('hasForgeTag', () => { 14 + it('returns true for forge-workspace tag', () => { 15 + expect(hasForgeTag('["forge-workspace"]')).toBe(true); 16 + }); 17 + 18 + it('returns true for forge-report tag', () => { 19 + expect(hasForgeTag('["forge-report","other"]')).toBe(true); 20 + }); 21 + 22 + it('returns true when forge tag is alongside others', () => { 23 + expect(hasForgeTag('["pinned","forge-workspace","archived"]')).toBe(true); 24 + }); 25 + 26 + it('returns false for unrelated tags', () => { 27 + expect(hasForgeTag('["pinned","shared"]')).toBe(false); 28 + }); 29 + 30 + it('returns false for empty tag array', () => { 31 + expect(hasForgeTag('[]')).toBe(false); 32 + }); 33 + 34 + it('returns false for null tags field', () => { 35 + expect(hasForgeTag(null)).toBe(false); 36 + }); 37 + 38 + it('returns false for undefined tags field', () => { 39 + expect(hasForgeTag(undefined)).toBe(false); 40 + }); 41 + 42 + it('returns false for empty string tags', () => { 43 + expect(hasForgeTag('')).toBe(false); 44 + }); 45 + 46 + it('returns false for malformed JSON', () => { 47 + expect(hasForgeTag('not-json')).toBe(false); 48 + expect(hasForgeTag('{"not":"array"}')).toBe(false); 49 + expect(hasForgeTag('[')).toBe(false); 50 + }); 51 + 52 + it('is case-sensitive on the tag value', () => { 53 + expect(hasForgeTag('["Forge-Workspace"]')).toBe(false); 54 + expect(hasForgeTag('["FORGE-WORKSPACE"]')).toBe(false); 55 + }); 56 + 57 + it('does not confuse similar-looking tags', () => { 58 + expect(hasForgeTag('["forge-done"]')).toBe(false); 59 + expect(hasForgeTag('["workspace"]')).toBe(false); 60 + }); 61 + });