Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions playground/src/pages/components/search-advanced.astro
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const headings = [
{ depth: 2, slug: 'index-format', text: 'Index format' },
{ depth: 2, slug: 'tabs', text: 'Tabs' },
{ depth: 2, slug: 'scope-selector', text: 'Scope selector' },
{ depth: 2, slug: 'two-phase-loading', text: 'Two-phase loading' },
{ depth: 2, slug: 'keyboard', text: 'Keyboard shortcut' },
{ depth: 2, slug: 'usage', text: 'Usage' },
];
Expand Down Expand Up @@ -82,6 +83,40 @@ const headings = [
"Current package" option is hidden automatically.
</p>

<h2 id="two-phase-loading">Two-phase loading</h2>
<p>
When the scope selector is set to <strong>All packages</strong> and the index is a
manifest listing many per-package/version files, the component fetches them in two
sequential phases to keep the search feel responsive:
</p>
<ol>
<li>
<strong>Phase 1</strong> — only the highest-version file for each package is fetched
in parallel. As soon as all phase-1 files arrive the first results are rendered and
the query is ready to use.
</li>
<li>
<strong>Phase 2</strong> — the remaining (older-version) files are fetched in parallel
in the background. The UI stays in a <em>pending</em> state: results are visually
greyed out, tab interactions are disabled, and a centered <em>Searching…</em> overlay
is displayed over the tabs. A 1 000 ms interval re-renders results as files accumulate,
so the counts update progressively rather than jumping at the end.
</li>
</ol>
<p>
Once every file has loaded the pending state is cleared, interactions are restored, and
the final result set (powered by <code>pickHighestVersionWinners()</code>) is shown.
Subsequent searches against the same scope reuse the already-fetched index — no
additional network requests are made.
</p>
<p>
The two-phase path is triggered automatically whenever
<code>latestPagesPromise</code> is non-null and
<code>allIndexLoadingComplete</code> is <code>false</code> at the time a search
runs. For single-file indexes or the current-package scope the standard single-phase
path is used instead.
</p>

<h2 id="keyboard">Keyboard shortcut</h2>
<p>
The trigger button supports <kbd>Ctrl</kbd>&nbsp;<kbd>K</kbd>
Expand Down
173 changes: 164 additions & 9 deletions src/components/SearchAdvanced/SearchAdvanced.astro
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,13 @@ const resolvedIndexUrl = indexUrl ?? `${basePath}api-search-index/manifest.json`
private allTabLimit: number = 10;
private showScope: boolean = false;
private kindCodeToCategory: Map<string, string> = new Map();
// Two-phase all-index loading: latestPagesPromise resolves with only the
// highest-version file per package (fast first result), allIndexPromise
// resolves when every version file is loaded (complete result).
private latestPagesPromise: Promise<IndexedPage[]> | null = null;
private allIndexLoadingComplete = false;
private allPagesAccumulator: IndexedPage[] = [];
private updateInterval: ReturnType<typeof setInterval> | null = null;

connectedCallback() {
this.basePath = this.dataset.base || '/';
Expand Down Expand Up @@ -506,6 +513,7 @@ const resolvedIndexUrl = indexUrl ?? `${basePath}api-search-index/manifest.json`
}

private closeModal() {
this.clearUpdateInterval();
this.dialog.hide();
}

Expand All @@ -531,24 +539,43 @@ const resolvedIndexUrl = indexUrl ?? `${basePath}api-search-index/manifest.json`
}

private async fetchIndex(): Promise<IndexedPage[]> {
// Create latestPagesPromise SYNCHRONOUSLY before the first await so that
// runSearch() can detect two-phase loading is in progress the moment
// fetchIndex() is called (e.g. immediately after scope changes to 'all').
let latestResolve!: (pages: IndexedPage[]) => void;
if (!this.latestPagesPromise) {
this.latestPagesPromise = new Promise(res => { latestResolve = res; });
}

let data: unknown;
try {
const r = await fetch(this.indexUrl);
if (!r.ok) return [];
if (!r.ok) { this.allIndexLoadingComplete = true; latestResolve([]); return []; }
data = await r.json();
} catch {
this.allIndexLoadingComplete = true;
latestResolve([]);
return [];
}
Comment thread
igdmdimitrov marked this conversation as resolved.

// Single-file format: { pages: [...] }
if (Array.isArray((data as any)?.pages)) {
return (data as any).pages as IndexedPage[];
this.allIndexLoadingComplete = true;
const pages = (data as any).pages as IndexedPage[];
latestResolve(pages);
return pages;
}

// Manifest format: { files: ["pkg/version.json", ...] }
if (Array.isArray((data as any)?.files)) {
const baseUrl = this.indexUrl.replace(/[^/]*$/, '');
const fetches: Promise<IndexedPage[]>[] = ((data as any).files as string[]).map(async (file: string) => {
const files: string[] = (data as any).files as string[];
this.allPagesAccumulator = [];

const latestFileSet = new Set(pickLatestFiles(files));
const otherFiles = files.filter(f => !latestFileSet.has(f));

const fetchFile = async (file: string): Promise<IndexedPage[]> => {
try {
const res = await fetch(`${baseUrl}${file}`);
if (!res.ok) return [];
Expand All @@ -557,11 +584,28 @@ const resolvedIndexUrl = indexUrl ?? `${basePath}api-search-index/manifest.json`
} catch {
return [];
}
});
const results = await Promise.all(fetches);
return results.flat();
};

// Phase 1: fetch latest-version files in parallel, await all before
// starting phase 2. latestPagesPromise resolves when this completes.
const phase1 = await Promise.all(Array.from(latestFileSet).map(fetchFile));
const latestPages = phase1.flat();
this.allPagesAccumulator.push(...latestPages);
latestResolve(latestPages);

// Phase 2: fetch remaining files in parallel; accumulate pages as each
// arrives so the 1000ms interval in runSearch can re-render partial results.
await Promise.all(otherFiles.map(async (file) => {
const pages = await fetchFile(file);
this.allPagesAccumulator.push(...pages);
}));

this.allIndexLoadingComplete = true;
return this.allPagesAccumulator;
}

this.allIndexLoadingComplete = true;
latestResolve([]);
return [];
Comment thread
igdmdimitrov marked this conversation as resolved.
}

Expand All @@ -588,6 +632,10 @@ const resolvedIndexUrl = indexUrl ?? `${basePath}api-search-index/manifest.json`
// Avoid searching on very short queries — a single or two-character query matches
// virtually everything and causes the classify + render path to freeze the main thread.
if (q.length < 3) {
this.clearUpdateInterval();
this.tabsEl.removeAttribute('data-search-pending');
const contentElShort = this.tabsEl.closest<HTMLElement>('.igd-search-content');
if (contentElShort) contentElShort.removeAttribute('data-search-pending');
this.statusEl.textContent = 'Type at least 3 characters to search\u2026';
this.tabsEl.hidden = true;
this.tabsEl.innerHTML = '';
Expand All @@ -596,34 +644,111 @@ const resolvedIndexUrl = indexUrl ?? `${basePath}api-search-index/manifest.json`
return;
Comment thread
igdmdimitrov marked this conversation as resolved.
}

this.statusEl.textContent = 'Searching\u2026';
// Two-phase search for "all packages" scope when the full index is not
// yet loaded: show latest-version results immediately (greyed out /
// non-interactive), then update every 1000ms as phase 2 files arrive.
const useLatestFirst =
this.scope === 'all' &&
this.latestPagesPromise !== null &&
!this.allIndexLoadingComplete;
Comment on lines +650 to +653

if (useLatestFirst) {
// Immediately activate the pending overlay so the CSS ::after "Searching…"
// text appears centered over the igc-tabs area rather than in the status
// element above the tabs. renderTabs(true) will maintain this once phase
// 1 results are ready.
this.statusEl.textContent = 'Searching\u2026';
this.tabsEl.toggleAttribute('data-search-pending', true);
const contentElEarly = this.tabsEl.closest<HTMLElement>('.igd-search-content');
if (contentElEarly) contentElEarly.toggleAttribute('data-search-pending', true);
} else {
this.statusEl.textContent = 'Searching\u2026';
}
Comment thread
igdmdimitrov marked this conversation as resolved.

if (useLatestFirst) {
let latestPages: IndexedPage[];
try {
latestPages = await this.latestPagesPromise!;
} catch {
latestPages = [];
}
if (q !== this.currentQuery) return;

if (latestPages.length > 0) {
const buckets = this.classify(latestPages, q);
this.currentMatches = buckets;
if (this.currentTab !== 'all' && (buckets.get(this.currentTab)?.length ?? 0) === 0) {
this.currentTab = 'all';
}
this.renderTabs(true);
this.renderResults();
}

// Update results every 1000ms while phase 2 files are still arriving.
this.clearUpdateInterval();
let lastLen = 0;
this.updateInterval = setInterval(() => {
if (q !== this.currentQuery) { this.clearUpdateInterval(); return; }
const len = this.allPagesAccumulator.length;
const complete = this.allIndexLoadingComplete;
if (len === 0) { if (complete) this.clearUpdateInterval(); return; }
if (len === lastLen && !complete) return;
lastLen = len;
const buckets = this.classify(this.allPagesAccumulator, q);
this.currentMatches = buckets;
if (this.currentTab !== 'all' && (buckets.get(this.currentTab)?.length ?? 0) === 0) {
this.currentTab = 'all';
}
// Keep pending=true while still loading; the final render below clears it.
this.renderTabs(!complete);
this.renderResults();
if (complete) this.clearUpdateInterval();
}, 1000);
Comment thread
igdmdimitrov marked this conversation as resolved.
Comment thread
igdmdimitrov marked this conversation as resolved.
this.dialog.addEventListener('igcClosed', () => this.clearUpdateInterval(), { once: true });
}
Comment on lines +704 to +708

let pages: IndexedPage[];
try {
pages = await this.loadIndex();
} catch (err) {
this.clearUpdateInterval();
this.tabsEl.toggleAttribute('data-search-pending', false);
const contentEl = this.tabsEl.closest<HTMLElement>('.igd-search-content');
if (contentEl) contentEl.removeAttribute('data-search-pending');
this.statusEl.textContent = `Search unavailable: ${(err as Error).message}`;
return;
}
Comment thread
igdmdimitrov marked this conversation as resolved.

this.clearUpdateInterval();
if (q !== this.currentQuery) return;

const buckets = this.classify(pages, q);
this.currentMatches = buckets;
if (this.currentTab !== 'all' && (buckets.get(this.currentTab)?.length ?? 0) === 0) {
this.currentTab = 'all';
}
this.renderTabs();
this.renderTabs(false);
this.renderResults();
}

private clearUpdateInterval() {
if (this.updateInterval !== null) {
clearInterval(this.updateInterval);
this.updateInterval = null;
}
}

private clearResults() {
this.clearUpdateInterval();
this.statusEl.textContent = '';
this.tabsEl.hidden = true;
this.tabsEl.innerHTML = '';
this.flatListEl.hidden = true;
this.flatListEl.innerHTML = '';
this.currentMatches = new Map();
this.tabsEl.removeAttribute('data-search-pending');
const contentEl = this.tabsEl.closest<HTMLElement>('.igd-search-content');
if (contentEl) contentEl.removeAttribute('data-search-pending');
}
Comment thread
igdmdimitrov marked this conversation as resolved.

private async runBrowse() {
Expand Down Expand Up @@ -845,7 +970,7 @@ const resolvedIndexUrl = indexUrl ?? `${basePath}api-search-index/manifest.json`
return buckets;
}

private renderTabs() {
private renderTabs(pending = false) {
const totalCount = Array.from(this.currentMatches.values()).reduce((s, l) => s + l.length, 0);

// No-categories fallback: hide tabs, render a plain list
Expand Down Expand Up @@ -910,11 +1035,21 @@ const resolvedIndexUrl = indexUrl ?? `${basePath}api-search-index/manifest.json`
if (totalCount === 0) {
this.tabsEl.hidden = true;
this.tabsEl.innerHTML = '';
this.tabsEl.removeAttribute('data-search-pending');
const contentEl = this.tabsEl.closest<HTMLElement>('.igd-search-content');
if (contentEl) contentEl.removeAttribute('data-search-pending');
return;
}

this.tabsEl.innerHTML = tabsHtml.join('');
this.tabsEl.hidden = false;
this.tabsEl.toggleAttribute('data-search-pending', pending);
this.tabsEl.toggleAttribute('inert', pending);
const contentEl = this.tabsEl.closest<HTMLElement>('.igd-search-content');
if (contentEl) contentEl.toggleAttribute('data-search-pending', pending);
Comment thread
igdmdimitrov marked this conversation as resolved.
Comment thread
igdmdimitrov marked this conversation as resolved.
// Keep the aria-live status text during pending so screen readers still
// get a "Searching…" announcement; visually hide it via CSS if desired.
if (pending) this.statusEl.textContent = 'Searching\u2026';

// Wire "Show more" buttons — select the target tab (its content is already rendered)
this.tabsEl.querySelectorAll<HTMLButtonElement>('.igd-search-more-btn').forEach((btn) => {
Expand Down Expand Up @@ -991,6 +1126,26 @@ const resolvedIndexUrl = indexUrl ?? `${basePath}api-search-index/manifest.json`
return Array.from(winners.values());
}

/**
* From a manifest file list ("pkg/version.json" entries), return only the
* highest-version file per package. These are the files fetched in the fast
* first phase of the two-phase all-packages search.
*/
function pickLatestFiles(files: string[]): string[] {
const best = new Map<string, { version: string; file: string }>();
for (const file of files) {
const slash = file.indexOf('/');
if (slash < 0) continue;
const pkg = file.slice(0, slash);
const version = file.slice(slash + 1).replace(/\.json$/, '');
const existing = best.get(pkg);
Comment on lines +1136 to +1141
if (!existing || compareVersions(version, existing.version) > 0) {
best.set(pkg, { version, file });
}
}
return Array.from(best.values()).map(e => e.file);
}

function highlight(text: string, q: string): string {
if (!q) return escapeHtml(text);
const i = text.toLowerCase().indexOf(q);
Expand Down
56 changes: 55 additions & 1 deletion src/components/SearchAdvanced/SearchAdvanced.scss
Original file line number Diff line number Diff line change
Expand Up @@ -154,5 +154,59 @@
letter-spacing: 0;
}
}
}

// Pending state: results from latest-version-only first phase are shown
// greyed out and non-interactive while remaining version files load.
.igd-search-content {
position: relative;

&[data-search-pending]::after {
content: 'Searching\2026';
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: light-dark(var(--ig-gray-600), var(--ig-gray-300));
font-size: #{rem(14px)};
font-weight: 500;
pointer-events: none;
z-index: 1;
}

// Keep the aria-live status element in the accessibility tree while pending,
// but visually hide it so the overlay is the only visible "Searching…".
&[data-search-pending] .igd-search-status {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
}
Comment thread
igdmdimitrov marked this conversation as resolved.

.igd-search-tabs[data-search-pending] {
pointer-events: none;

&::part(selected-indicator),
&::part(start-scroll-button),
&::part(end-scroll-button) {
opacity: 0.25;
}

igc-tab::part(content),
igc-tab::part(panel) {
opacity: 0.25;
}

.igd-search-results,
.igd-search-category-header,
.igd-search-tab-count {
opacity: 0.25;
}
}
}