···44 * Since all docs are E2EE, content search must decrypt each document client-side.
55 * This module fetches snapshots, decrypts them, extracts text, and searches.
66 * Results are displayed below the document list with match snippets.
77+ *
88+ * Forge workspaces and reports are surfaced in a dedicated section above the
99+ * regular content matches so workspace history is findable by content, not
1010+ * just by title.
711 */
812913import { importKey, decrypt } from './lib/crypto.js';
···1721 keyStr: string;
1822 snippet: string;
1923 matchCount: number;
2424+ /** True if the document is tagged as a Forge workspace or report. */
2525+ isForgeWorkspace: boolean;
2626+}
2727+2828+/**
2929+ * Check if a document's tag string marks it as a Forge workspace.
3030+ * Exported for testing.
3131+ */
3232+export function hasForgeTag(tagsField: string | null | undefined): boolean {
3333+ if (!tagsField) return false;
3434+ try {
3535+ const parsed = JSON.parse(tagsField);
3636+ if (!Array.isArray(parsed)) return false;
3737+ return parsed.includes('forge-workspace') || parsed.includes('forge-report');
3838+ } catch {
3939+ return false;
4040+ }
2041}
21422243const MAX_CACHE_SIZE = 50;
···2950 */
3051export async function searchContent(
3152 query: string,
3232- docs: Array<{ id: string; type: string; _decryptedName?: string; _keyStr?: string }>,
5353+ docs: Array<{ id: string; type: string; tags?: string | null; _decryptedName?: string; _keyStr?: string }>,
3354): Promise<ContentSearchResult[]> {
3455 if (!query || query.length < 3) return [];
3556···90111 keyStr: doc._keyStr || '',
91112 snippet,
92113 matchCount: positions.length,
114114+ isForgeWorkspace: hasForgeTag(doc.tags),
93115 });
94116 }
951179696- // Sort by match count descending
9797- results.sort((a, b) => b.matchCount - a.matchCount);
118118+ // Sort Forge workspaces first (most meaningful context), then by match count
119119+ results.sort((a, b) => {
120120+ if (a.isForgeWorkspace !== b.isForgeWorkspace) {
121121+ return a.isForgeWorkspace ? -1 : 1;
122122+ }
123123+ return b.matchCount - a.matchCount;
124124+ });
9812599126 return results.slice(0, 20); // Cap at 20 results
100127}
···116143 container.style.display = '';
117144 const queryLower = query.toLowerCase();
118145119119- let html = `<div class="content-search-header">Content matches <span class="content-search-count">${results.length}</span></div>`;
120120- html += '<div class="content-search-list">';
146146+ const forgeResults = results.filter(r => r.isForgeWorkspace);
147147+ const regularResults = results.filter(r => !r.isForgeWorkspace);
121148122122- for (const r of results) {
123123- const path = docPath(r.docType);
124124- const href = r.keyStr ? `${path}/${r.docId}#${r.keyStr}` : '#';
125125- // Highlight the match in the snippet
126126- const snippetHtml = highlightSnippet(r.snippet, queryLower);
149149+ let html = '';
127150128128- html += `<a class="content-search-item" href="${href}">`;
129129- html += `<span class="content-search-name">${escapeHtml(r.docName)}</span>`;
130130- html += `<span class="content-search-snippet">${snippetHtml}</span>`;
131131- html += `<span class="content-search-meta">${r.matchCount} match${r.matchCount !== 1 ? 'es' : ''}</span>`;
132132- html += '</a>';
151151+ if (forgeResults.length > 0) {
152152+ html += `<div class="content-search-header content-search-header--forge">\u2692 Forge workspaces <span class="content-search-count">${forgeResults.length}</span></div>`;
153153+ html += '<div class="content-search-list">';
154154+ for (const r of forgeResults) {
155155+ html += renderSearchItem(r, queryLower);
156156+ }
157157+ html += '</div>';
133158 }
134159135135- html += '</div>';
160160+ if (regularResults.length > 0) {
161161+ html += `<div class="content-search-header">Content matches <span class="content-search-count">${regularResults.length}</span></div>`;
162162+ html += '<div class="content-search-list">';
163163+ for (const r of regularResults) {
164164+ html += renderSearchItem(r, queryLower);
165165+ }
166166+ html += '</div>';
167167+ }
168168+136169 container.innerHTML = html;
170170+}
171171+172172+function renderSearchItem(r: ContentSearchResult, queryLower: string): string {
173173+ const path = docPath(r.docType);
174174+ const href = r.keyStr ? `${path}/${r.docId}#${r.keyStr}` : '#';
175175+ const snippetHtml = highlightSnippet(r.snippet, queryLower);
176176+ const badge = r.isForgeWorkspace
177177+ ? '<span class="content-search-badge content-search-badge--forge">forge</span>'
178178+ : '';
179179+180180+ return `<a class="content-search-item${r.isForgeWorkspace ? ' content-search-item--forge' : ''}" href="${href}">` +
181181+ `<span class="content-search-name">${escapeHtml(r.docName)}${badge}</span>` +
182182+ `<span class="content-search-snippet">${snippetHtml}</span>` +
183183+ `<span class="content-search-meta">${r.matchCount} match${r.matchCount !== 1 ? 'es' : ''}</span>` +
184184+ '</a>';
137185}
138186139187function highlightSnippet(snippet: string, queryLower: string): string {
+25
src/landing-render.ts
···2727export function docPath(type: string): string { return DOC_PATH_MAP[type] || '/sheets'; }
2828export function docIcon(type: string): string { return DOC_ICON_MAP[type] || '▦'; }
29293030+/**
3131+ * Returns true when a document is a Forge workspace or report. These docs
3232+ * get their own dedicated section on the landing page and should be
3333+ * filtered out of the main doc feed so they don't crowd user content.
3434+ *
3535+ * Exported as a pure helper for tests and reuse by other filter callers.
3636+ */
3737+export function isForgeDoc(doc: { tags: string | null }): boolean {
3838+ const tags = parseTags(doc.tags);
3939+ return tags.includes('forge-workspace') || tags.includes('forge-report');
4040+}
4141+3042export function escapeHtml(text: string): string {
3143 const div = document.createElement('div');
3244 div.textContent = text;
···446458447459 // Apply type filter
448460 visibleDocs = filterByType(visibleDocs, activeTypeFilter);
461461+462462+ // Hide Forge workspaces from the main feed. They have their own dedicated
463463+ // section rendered by renderForgeWorkspaces. Exceptions where the user has
464464+ // explicitly asked to see them:
465465+ // - a forge-* tag is the active tag filter
466466+ // - the doc lives in a user-created folder (explicit placement)
467467+ // - a search is active (user wants to find-anything)
468468+ const forgeTagFilterActive =
469469+ activeTagFilter === 'forge-workspace' || activeTagFilter === 'forge-report' || activeTagFilter === 'forge-done';
470470+ const hideForge = !forgeTagFilterActive && currentFolderId === null && !searchQuery;
471471+ if (hideForge) {
472472+ visibleDocs = visibleDocs.filter(d => !isForgeDoc(d));
473473+ }
449474450475 // Render tag filter bar
451476 renderTagFilter(deps, active);
+49
tests/landing-forge-hide.test.ts
···11+import { describe, it, expect } from 'vitest';
22+33+/**
44+ * Tests for `isForgeDoc` — the predicate that keeps Forge workspaces out
55+ * of the main landing page feed.
66+ *
77+ * The predicate reads the same tag convention as the Forge section, so
88+ * a doc tagged forge-workspace or forge-report should return true.
99+ */
1010+1111+import { isForgeDoc } from '../src/landing-render.js';
1212+1313+describe('isForgeDoc', () => {
1414+ it('matches forge-workspace tag', () => {
1515+ expect(isForgeDoc({ tags: '["forge-workspace"]' })).toBe(true);
1616+ });
1717+1818+ it('matches forge-report tag', () => {
1919+ expect(isForgeDoc({ tags: '["forge-report"]' })).toBe(true);
2020+ });
2121+2222+ it('matches when forge-done is set alongside forge-workspace', () => {
2323+ expect(isForgeDoc({ tags: '["forge-workspace","forge-done"]' })).toBe(true);
2424+ });
2525+2626+ it('matches when forge tag is mixed with user tags', () => {
2727+ expect(isForgeDoc({ tags: '["pinned","forge-workspace"]' })).toBe(true);
2828+ });
2929+3030+ it('does not match unrelated tags', () => {
3131+ expect(isForgeDoc({ tags: '["pinned","shared"]' })).toBe(false);
3232+ });
3333+3434+ it('does not match forge-done alone (archived workspace still has forge-workspace in practice)', () => {
3535+ expect(isForgeDoc({ tags: '["forge-done"]' })).toBe(false);
3636+ });
3737+3838+ it('handles null tags', () => {
3939+ expect(isForgeDoc({ tags: null })).toBe(false);
4040+ });
4141+4242+ it('handles empty string tags', () => {
4343+ expect(isForgeDoc({ tags: '' })).toBe(false);
4444+ });
4545+4646+ it('handles empty array', () => {
4747+ expect(isForgeDoc({ tags: '[]' })).toBe(false);
4848+ });
4949+});
+61
tests/landing-workspace-search.test.ts
···11+import { describe, it, expect } from 'vitest';
22+33+/**
44+ * Unit tests for the Forge workspace search helpers.
55+ *
66+ * Covers the tag detection used to decide whether a content-search hit
77+ * should be surfaced as a Forge workspace result. The full search flow
88+ * is not exercised here — it needs decrypted snapshots and the network.
99+ */
1010+1111+import { hasForgeTag } from '../src/landing-content-search.js';
1212+1313+describe('hasForgeTag', () => {
1414+ it('returns true for forge-workspace tag', () => {
1515+ expect(hasForgeTag('["forge-workspace"]')).toBe(true);
1616+ });
1717+1818+ it('returns true for forge-report tag', () => {
1919+ expect(hasForgeTag('["forge-report","other"]')).toBe(true);
2020+ });
2121+2222+ it('returns true when forge tag is alongside others', () => {
2323+ expect(hasForgeTag('["pinned","forge-workspace","archived"]')).toBe(true);
2424+ });
2525+2626+ it('returns false for unrelated tags', () => {
2727+ expect(hasForgeTag('["pinned","shared"]')).toBe(false);
2828+ });
2929+3030+ it('returns false for empty tag array', () => {
3131+ expect(hasForgeTag('[]')).toBe(false);
3232+ });
3333+3434+ it('returns false for null tags field', () => {
3535+ expect(hasForgeTag(null)).toBe(false);
3636+ });
3737+3838+ it('returns false for undefined tags field', () => {
3939+ expect(hasForgeTag(undefined)).toBe(false);
4040+ });
4141+4242+ it('returns false for empty string tags', () => {
4343+ expect(hasForgeTag('')).toBe(false);
4444+ });
4545+4646+ it('returns false for malformed JSON', () => {
4747+ expect(hasForgeTag('not-json')).toBe(false);
4848+ expect(hasForgeTag('{"not":"array"}')).toBe(false);
4949+ expect(hasForgeTag('[')).toBe(false);
5050+ });
5151+5252+ it('is case-sensitive on the tag value', () => {
5353+ expect(hasForgeTag('["Forge-Workspace"]')).toBe(false);
5454+ expect(hasForgeTag('["FORGE-WORKSPACE"]')).toBe(false);
5555+ });
5656+5757+ it('does not confuse similar-looking tags', () => {
5858+ expect(hasForgeTag('["forge-done"]')).toBe(false);
5959+ expect(hasForgeTag('["workspace"]')).toBe(false);
6060+ });
6161+});