Add URL-synced listing search (#1217)

* Add URL-synced listing search

Closes #1174

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Aaron Powell
2026-03-30 12:26:37 +11:00
committed by GitHub
parent 1edf5bc29d
commit aa86725613
9 changed files with 401 additions and 17 deletions

View File

@@ -12,6 +12,16 @@ export function getChoicesValues(choices: Choices): string[] {
return Array.isArray(val) ? val : (val ? [val] : []);
}
/**
* Restore selected values on a Choices instance.
*/
export function setChoicesValues(choices: Choices, values: string[]): void {
// Clear any existing active items so that the final selection matches `values`
choices.removeActiveItems();
// Set all provided values as the current selection
choices.setChoiceByValue(values);
}
/**
* Create a new Choices instance with sensible defaults
*/

View File

@@ -1,9 +1,23 @@
/**
* Agents page functionality
*/
import { createChoices, getChoicesValues, type Choices } from '../choices';
import {
createChoices,
getChoicesValues,
setChoicesValues,
type Choices,
} from '../choices';
import { FuzzySearch, type SearchItem } from '../search';
import { fetchData, debounce, setupDropdownCloseHandlers, setupActionHandlers } from '../utils';
import {
fetchData,
debounce,
getQueryParam,
getQueryParamFlag,
getQueryParamValues,
setupDropdownCloseHandlers,
setupActionHandlers,
updateQueryParams,
} from '../utils';
import { setupModal, openFileModal } from '../modal';
import { renderAgentsHtml, sortAgents, type AgentSortOption, type RenderableAgent } from './agents-render';
@@ -111,6 +125,16 @@ function setupResourceListHandlers(list: HTMLElement | null): void {
resourceListHandlersReady = true;
}
function syncUrlState(searchInput: HTMLInputElement | null): void {
updateQueryParams({
q: searchInput?.value ?? '',
model: currentFilters.models,
tool: currentFilters.tools,
handoffs: currentFilters.hasHandoffs,
sort: currentSort === 'title' ? '' : currentSort,
});
}
export async function initAgentsPage(): Promise<void> {
const list = document.getElementById('resource-list');
const searchInput = document.getElementById('search-input') as HTMLInputElement;
@@ -132,23 +156,46 @@ export async function initAgentsPage(): Promise<void> {
// Initialize Choices.js for model filter
modelSelect = createChoices('#filter-model', { placeholderValue: 'All Models' });
modelSelect.setChoices(data.filters.models.map(m => ({ value: m, label: m })), 'value', 'label', true);
const initialQuery = getQueryParam('q');
const initialModels = getQueryParamValues('model').filter(model => data.filters.models.includes(model));
const initialTools = getQueryParamValues('tool').filter(tool => data.filters.tools.includes(tool));
const initialSort = getQueryParam('sort');
if (searchInput) searchInput.value = initialQuery;
if (initialModels.length > 0) {
currentFilters.models = initialModels;
setChoicesValues(modelSelect, initialModels);
}
document.getElementById('filter-model')?.addEventListener('change', () => {
currentFilters.models = getChoicesValues(modelSelect);
applyFiltersAndRender();
syncUrlState(searchInput);
});
// Initialize Choices.js for tool filter
toolSelect = createChoices('#filter-tool', { placeholderValue: 'All Tools' });
toolSelect.setChoices(data.filters.tools.map(t => ({ value: t, label: t })), 'value', 'label', true);
if (initialTools.length > 0) {
currentFilters.tools = initialTools;
setChoicesValues(toolSelect, initialTools);
}
document.getElementById('filter-tool')?.addEventListener('change', () => {
currentFilters.tools = getChoicesValues(toolSelect);
applyFiltersAndRender();
syncUrlState(searchInput);
});
// Initialize sort select
if (initialSort === 'lastUpdated') {
currentSort = initialSort;
if (sortSelect) sortSelect.value = initialSort;
}
sortSelect?.addEventListener('change', () => {
currentSort = sortSelect.value as AgentSortOption;
applyFiltersAndRender();
syncUrlState(searchInput);
});
const countEl = document.getElementById('results-count');
@@ -156,11 +203,20 @@ export async function initAgentsPage(): Promise<void> {
countEl.textContent = `${allItems.length} of ${allItems.length} agents`;
}
searchInput?.addEventListener('input', debounce(() => applyFiltersAndRender(), 200));
searchInput?.addEventListener('input', debounce(() => {
applyFiltersAndRender();
syncUrlState(searchInput);
}, 200));
if (getQueryParamFlag('handoffs')) {
currentFilters.hasHandoffs = true;
if (handoffsCheckbox) handoffsCheckbox.checked = true;
}
handoffsCheckbox?.addEventListener('change', () => {
currentFilters.hasHandoffs = handoffsCheckbox.checked;
applyFiltersAndRender();
syncUrlState(searchInput);
});
clearFiltersBtn?.addEventListener('click', () => {
@@ -172,8 +228,10 @@ export async function initAgentsPage(): Promise<void> {
if (searchInput) searchInput.value = '';
if (sortSelect) sortSelect.value = 'title';
applyFiltersAndRender();
syncUrlState(searchInput);
});
applyFiltersAndRender();
setupModal();
setupDropdownCloseHandlers();
setupActionHandlers();

View File

@@ -1,13 +1,21 @@
/**
* Hooks page functionality
*/
import { createChoices, getChoicesValues, type Choices } from "../choices";
import {
createChoices,
getChoicesValues,
setChoicesValues,
type Choices,
} from "../choices";
import { FuzzySearch, type SearchItem } from "../search";
import {
fetchData,
debounce,
getQueryParam,
getQueryParamValues,
showToast,
downloadZipBundle,
updateQueryParams,
} from "../utils";
import { setupModal, openFileModal } from "../modal";
import {
@@ -126,6 +134,15 @@ function setupResourceListHandlers(list: HTMLElement | null): void {
resourceListHandlersReady = true;
}
function syncUrlState(searchInput: HTMLInputElement | null): void {
updateQueryParams({
q: searchInput?.value ?? "",
hook: currentFilters.hooks,
tag: currentFilters.tags,
sort: currentSort === "title" ? "" : currentSort,
});
}
async function downloadHook(
hookId: string,
btn: HTMLButtonElement
@@ -210,9 +227,26 @@ export async function initHooksPage(): Promise<void> {
"label",
true
);
const initialQuery = getQueryParam("q");
const initialHooks = getQueryParamValues("hook").filter((hook) =>
data.filters.hooks.includes(hook)
);
const initialTags = getQueryParamValues("tag").filter((tag) =>
data.filters.tags.includes(tag)
);
const initialSort = getQueryParam("sort");
if (searchInput) searchInput.value = initialQuery;
if (initialHooks.length > 0) {
currentFilters.hooks = initialHooks;
setChoicesValues(hookSelect, initialHooks);
}
document.getElementById("filter-hook")?.addEventListener("change", () => {
currentFilters.hooks = getChoicesValues(hookSelect);
applyFiltersAndRender();
syncUrlState(searchInput);
});
// Setup tag filter
@@ -225,20 +259,33 @@ export async function initHooksPage(): Promise<void> {
"label",
true
);
if (initialTags.length > 0) {
currentFilters.tags = initialTags;
setChoicesValues(tagSelect, initialTags);
}
document.getElementById("filter-tag")?.addEventListener("change", () => {
currentFilters.tags = getChoicesValues(tagSelect);
applyFiltersAndRender();
syncUrlState(searchInput);
});
if (initialSort === "lastUpdated") {
currentSort = initialSort;
if (sortSelect) sortSelect.value = initialSort;
}
sortSelect?.addEventListener("change", () => {
currentSort = sortSelect.value as HookSortOption;
applyFiltersAndRender();
syncUrlState(searchInput);
});
applyFiltersAndRender();
searchInput?.addEventListener(
"input",
debounce(() => applyFiltersAndRender(), 200)
debounce(() => {
applyFiltersAndRender();
syncUrlState(searchInput);
}, 200)
);
clearFiltersBtn?.addEventListener("click", () => {
@@ -249,6 +296,7 @@ export async function initHooksPage(): Promise<void> {
if (searchInput) searchInput.value = "";
if (sortSelect) sortSelect.value = "title";
applyFiltersAndRender();
syncUrlState(searchInput);
});
setupModal();

View File

@@ -1,9 +1,22 @@
/**
* Instructions page functionality
*/
import { createChoices, getChoicesValues, type Choices } from '../choices';
import {
createChoices,
getChoicesValues,
setChoicesValues,
type Choices,
} from '../choices';
import { FuzzySearch, type SearchItem } from '../search';
import { fetchData, debounce, setupDropdownCloseHandlers, setupActionHandlers } from '../utils';
import {
fetchData,
debounce,
getQueryParam,
getQueryParamValues,
setupDropdownCloseHandlers,
setupActionHandlers,
updateQueryParams,
} from '../utils';
import { setupModal, openFileModal } from '../modal';
import {
renderInstructionsHtml,
@@ -93,6 +106,14 @@ function setupResourceListHandlers(list: HTMLElement | null): void {
resourceListHandlersReady = true;
}
function syncUrlState(searchInput: HTMLInputElement | null): void {
updateQueryParams({
q: searchInput?.value ?? '',
extension: currentFilters.extensions,
sort: currentSort === 'title' ? '' : currentSort,
});
}
export async function initInstructionsPage(): Promise<void> {
const list = document.getElementById('resource-list');
const searchInput = document.getElementById('search-input') as HTMLInputElement;
@@ -112,14 +133,31 @@ export async function initInstructionsPage(): Promise<void> {
extensionSelect = createChoices('#filter-extension', { placeholderValue: 'All Extensions' });
extensionSelect.setChoices(data.filters.extensions.map(e => ({ value: e, label: e })), 'value', 'label', true);
const initialQuery = getQueryParam('q');
const initialExtensions = getQueryParamValues('extension').filter(extension => data.filters.extensions.includes(extension));
const initialSort = getQueryParam('sort');
if (searchInput) searchInput.value = initialQuery;
if (initialExtensions.length > 0) {
currentFilters.extensions = initialExtensions;
setChoicesValues(extensionSelect, initialExtensions);
}
if (initialSort === 'lastUpdated') {
currentSort = initialSort;
if (sortSelect) sortSelect.value = initialSort;
}
document.getElementById('filter-extension')?.addEventListener('change', () => {
currentFilters.extensions = getChoicesValues(extensionSelect);
applyFiltersAndRender();
syncUrlState(searchInput);
});
sortSelect?.addEventListener('change', () => {
currentSort = sortSelect.value as InstructionSortOption;
applyFiltersAndRender();
syncUrlState(searchInput);
});
const countEl = document.getElementById('results-count');
@@ -127,7 +165,10 @@ export async function initInstructionsPage(): Promise<void> {
countEl.textContent = `${allItems.length} of ${allItems.length} instructions`;
}
searchInput?.addEventListener('input', debounce(() => applyFiltersAndRender(), 200));
searchInput?.addEventListener('input', debounce(() => {
applyFiltersAndRender();
syncUrlState(searchInput);
}, 200));
clearFiltersBtn?.addEventListener('click', () => {
currentFilters = { extensions: [] };
@@ -136,8 +177,10 @@ export async function initInstructionsPage(): Promise<void> {
if (searchInput) searchInput.value = '';
if (sortSelect) sortSelect.value = 'title';
applyFiltersAndRender();
syncUrlState(searchInput);
});
applyFiltersAndRender();
setupModal();
setupDropdownCloseHandlers();
setupActionHandlers();

View File

@@ -1,9 +1,20 @@
/**
* Plugins page functionality
*/
import { createChoices, getChoicesValues, type Choices } from '../choices';
import {
createChoices,
getChoicesValues,
setChoicesValues,
type Choices,
} from '../choices';
import { FuzzySearch, type SearchItem } from '../search';
import { fetchData, debounce } from '../utils';
import {
fetchData,
debounce,
getQueryParam,
getQueryParamValues,
updateQueryParams,
} from '../utils';
import { setupModal, openFileModal } from '../modal';
import { renderPluginsHtml, type RenderablePlugin } from './plugins-render';
@@ -98,6 +109,13 @@ function setupResourceListHandlers(list: HTMLElement | null): void {
resourceListHandlersReady = true;
}
function syncUrlState(searchInput: HTMLInputElement | null): void {
updateQueryParams({
q: searchInput?.value ?? '',
tag: currentFilters.tags,
});
}
export async function initPluginsPage(): Promise<void> {
const list = document.getElementById('resource-list');
const searchInput = document.getElementById('search-input') as HTMLInputElement;
@@ -123,9 +141,20 @@ export async function initPluginsPage(): Promise<void> {
tagSelect = createChoices('#filter-tag', { placeholderValue: 'All Tags' });
tagSelect.setChoices(data.filters.tags.map(t => ({ value: t, label: t })), 'value', 'label', true);
const initialQuery = getQueryParam('q');
const initialTags = getQueryParamValues('tag').filter(tag => data.filters.tags.includes(tag));
if (searchInput) searchInput.value = initialQuery;
if (initialTags.length > 0) {
currentFilters.tags = initialTags;
setChoicesValues(tagSelect, initialTags);
}
document.getElementById('filter-tag')?.addEventListener('change', () => {
currentFilters.tags = getChoicesValues(tagSelect);
applyFiltersAndRender();
syncUrlState(searchInput);
});
const countEl = document.getElementById('results-count');
@@ -133,15 +162,20 @@ export async function initPluginsPage(): Promise<void> {
countEl.textContent = `${allItems.length} of ${allItems.length} plugins`;
}
searchInput?.addEventListener('input', debounce(() => applyFiltersAndRender(), 200));
searchInput?.addEventListener('input', debounce(() => {
applyFiltersAndRender();
syncUrlState(searchInput);
}, 200));
clearFiltersBtn?.addEventListener('click', () => {
currentFilters = { tags: [] };
tagSelect.removeActiveItems();
if (searchInput) searchInput.value = '';
applyFiltersAndRender();
syncUrlState(searchInput);
});
applyFiltersAndRender();
setupModal();
}

View File

@@ -1,13 +1,22 @@
/**
* Skills page functionality
*/
import { createChoices, getChoicesValues, type Choices } from "../choices";
import {
createChoices,
getChoicesValues,
setChoicesValues,
type Choices,
} from "../choices";
import { FuzzySearch, type SearchItem } from "../search";
import {
fetchData,
debounce,
getQueryParam,
getQueryParamFlag,
getQueryParamValues,
showToast,
downloadZipBundle,
updateQueryParams,
} from "../utils";
import { setupModal, openFileModal } from "../modal";
import {
@@ -120,6 +129,15 @@ function setupResourceListHandlers(list: HTMLElement | null): void {
resourceListHandlersReady = true;
}
function syncUrlState(searchInput: HTMLInputElement | null): void {
updateQueryParams({
q: searchInput?.value ?? "",
category: currentFilters.categories,
hasAssets: currentFilters.hasAssets,
sort: currentSort === "title" ? "" : currentSort,
});
}
async function downloadSkill(
skillId: string,
btn: HTMLButtonElement
@@ -189,25 +207,52 @@ export async function initSkillsPage(): Promise<void> {
"label",
true
);
const initialQuery = getQueryParam("q");
const initialCategories = getQueryParamValues("category").filter((category) =>
data.filters.categories.includes(category)
);
const initialSort = getQueryParam("sort");
if (searchInput) searchInput.value = initialQuery;
if (initialCategories.length > 0) {
currentFilters.categories = initialCategories;
setChoicesValues(categorySelect, initialCategories);
}
if (getQueryParamFlag("hasAssets")) {
currentFilters.hasAssets = true;
if (hasAssetsCheckbox) hasAssetsCheckbox.checked = true;
}
if (initialSort === "lastUpdated") {
currentSort = initialSort;
if (sortSelect) sortSelect.value = initialSort;
}
document.getElementById("filter-category")?.addEventListener("change", () => {
currentFilters.categories = getChoicesValues(categorySelect);
applyFiltersAndRender();
syncUrlState(searchInput);
});
sortSelect?.addEventListener("change", () => {
currentSort = sortSelect.value as SkillSortOption;
applyFiltersAndRender();
syncUrlState(searchInput);
});
applyFiltersAndRender();
searchInput?.addEventListener(
"input",
debounce(() => applyFiltersAndRender(), 200)
debounce(() => {
applyFiltersAndRender();
syncUrlState(searchInput);
}, 200)
);
hasAssetsCheckbox?.addEventListener("change", () => {
currentFilters.hasAssets = hasAssetsCheckbox.checked;
applyFiltersAndRender();
syncUrlState(searchInput);
});
clearFiltersBtn?.addEventListener("click", () => {
@@ -218,6 +263,7 @@ export async function initSkillsPage(): Promise<void> {
if (searchInput) searchInput.value = "";
if (sortSelect) sortSelect.value = "title";
applyFiltersAndRender();
syncUrlState(searchInput);
});
setupModal();

View File

@@ -2,7 +2,12 @@
* Tools page functionality
*/
import { FuzzySearch, type SearchableItem } from "../search";
import { fetchData, debounce } from "../utils";
import {
fetchData,
debounce,
getQueryParam,
updateQueryParams,
} from "../utils";
import { renderToolsHtml } from "./tools-render";
export interface Tool extends SearchableItem {
@@ -84,6 +89,13 @@ function renderTools(tools: Tool[], query = ""): void {
});
}
function syncUrlState(searchInput: HTMLInputElement | null): void {
updateQueryParams({
q: searchInput?.value ?? "",
category: currentFilters.categories,
});
}
function setupCopyConfigHandlers(): void {
if (copyHandlersReady) return;
@@ -157,18 +169,33 @@ export async function initToolsPage(): Promise<void> {
)
.join("");
const initialCategory = getQueryParam("category");
if (initialCategory && data.filters.categories.includes(initialCategory)) {
currentFilters.categories = [initialCategory];
categoryFilter.value = initialCategory;
}
categoryFilter.addEventListener("change", () => {
currentFilters.categories = categoryFilter.value
? [categoryFilter.value]
: [];
applyFiltersAndRender();
syncUrlState(searchInput);
});
}
const initialQuery = getQueryParam("q");
if (searchInput) searchInput.value = initialQuery;
applyFiltersAndRender();
// Search input handler
searchInput?.addEventListener(
"input",
debounce(() => applyFiltersAndRender(), 200)
debounce(() => {
applyFiltersAndRender();
syncUrlState(searchInput);
}, 200)
);
// Clear filters
@@ -177,6 +204,7 @@ export async function initToolsPage(): Promise<void> {
if (categoryFilter) categoryFilter.value = "";
if (searchInput) searchInput.value = "";
applyFiltersAndRender();
syncUrlState(searchInput);
});
setupCopyConfigHandlers();

View File

@@ -1,12 +1,20 @@
/**
* Workflows page functionality
*/
import { createChoices, getChoicesValues, type Choices } from "../choices";
import {
createChoices,
getChoicesValues,
setChoicesValues,
type Choices,
} from "../choices";
import { FuzzySearch, type SearchItem } from "../search";
import {
fetchData,
debounce,
getQueryParam,
getQueryParamValues,
setupActionHandlers,
updateQueryParams,
} from "../utils";
import { setupModal, openFileModal } from "../modal";
import {
@@ -106,6 +114,14 @@ function setupResourceListHandlers(list: HTMLElement | null): void {
resourceListHandlersReady = true;
}
function syncUrlState(searchInput: HTMLInputElement | null): void {
updateQueryParams({
q: searchInput?.value ?? "",
trigger: currentFilters.triggers,
sort: currentSort === "title" ? "" : currentSort,
});
}
export async function initWorkflowsPage(): Promise<void> {
const list = document.getElementById("resource-list");
const searchInput = document.getElementById(
@@ -139,14 +155,33 @@ export async function initWorkflowsPage(): Promise<void> {
"label",
true
);
const initialQuery = getQueryParam("q");
const initialTriggers = getQueryParamValues("trigger").filter((trigger) =>
data.filters.triggers.includes(trigger)
);
const initialSort = getQueryParam("sort");
if (searchInput) searchInput.value = initialQuery;
if (initialTriggers.length > 0) {
currentFilters.triggers = initialTriggers;
setChoicesValues(triggerSelect, initialTriggers);
}
if (initialSort === "lastUpdated") {
currentSort = initialSort;
if (sortSelect) sortSelect.value = initialSort;
}
document.getElementById("filter-trigger")?.addEventListener("change", () => {
currentFilters.triggers = getChoicesValues(triggerSelect);
applyFiltersAndRender();
syncUrlState(searchInput);
});
sortSelect?.addEventListener("change", () => {
currentSort = sortSelect.value as WorkflowSortOption;
applyFiltersAndRender();
syncUrlState(searchInput);
});
const countEl = document.getElementById("results-count");
@@ -156,7 +191,10 @@ export async function initWorkflowsPage(): Promise<void> {
searchInput?.addEventListener(
"input",
debounce(() => applyFiltersAndRender(), 200)
debounce(() => {
applyFiltersAndRender();
syncUrlState(searchInput);
}, 200)
);
clearFiltersBtn?.addEventListener("click", () => {
@@ -166,8 +204,10 @@ export async function initWorkflowsPage(): Promise<void> {
if (searchInput) searchInput.value = "";
if (sortSelect) sortSelect.value = "title";
applyFiltersAndRender();
syncUrlState(searchInput);
});
applyFiltersAndRender();
setupModal();
setupActionHandlers();
}

View File

@@ -235,6 +235,83 @@ export async function shareFile(filePath: string): Promise<boolean> {
return copyToClipboard(deepLinkUrl);
}
type QueryParamValue = string | string[] | boolean | null | undefined;
/**
* Read a single query parameter.
*/
export function getQueryParam(name: string): string {
if (typeof window === "undefined") return "";
return new URLSearchParams(window.location.search).get(name)?.trim() ?? "";
}
/**
* Read repeated query parameter values.
*/
export function getQueryParamValues(name: string): string[] {
if (typeof window === "undefined") return [];
const values = new URLSearchParams(window.location.search)
.getAll(name)
.map((value) => value.trim())
.filter(Boolean);
return Array.from(new Set(values));
}
/**
* Read a boolean-style query parameter.
*/
export function getQueryParamFlag(name: string): boolean {
const value = getQueryParam(name).toLowerCase();
return value === "1" || value === "true" || value === "yes";
}
/**
* Update query parameters while preserving the current hash.
*/
export function updateQueryParams(
updates: Record<string, QueryParamValue>
): void {
if (typeof window === "undefined") return;
const url = new URL(window.location.href);
for (const [key, value] of Object.entries(updates)) {
url.searchParams.delete(key);
if (Array.isArray(value)) {
for (const item of value) {
const normalized = item.trim();
if (normalized) {
url.searchParams.append(key, normalized);
}
}
continue;
}
if (typeof value === "boolean") {
if (value) {
url.searchParams.set(key, "1");
}
continue;
}
if (typeof value === "string") {
const normalized = value.trim();
if (normalized) {
url.searchParams.set(key, normalized);
}
}
}
const search = url.searchParams.toString();
const nextUrl = `${url.pathname}${search ? `?${search}` : ""}${url.hash}`;
const currentUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`;
if (nextUrl !== currentUrl) {
history.replaceState(null, "", nextUrl);
}
}
/**
* Show a toast notification
*/