diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java index 0206e395..8499ee4d 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java @@ -73,6 +73,23 @@ class RouteSecurityPolicyRegistryTest { assertTrue(matchedWeb); } + @Test + void authorizationPolicies_shouldNotDeclareNamespaceBundleDownloadRoutes() { + String v1Route = "/api/v1/namespaces/*/skills/" + "download"; + String webRoute = "/api/web/namespaces/*/skills/" + "download"; + boolean matchedV1 = registry.authorizationPolicies().stream() + .anyMatch(policy -> policy.method() == HttpMethod.GET + && v1Route.equals(policy.pattern())); + boolean matchedWeb = registry.authorizationPolicies().stream() + .anyMatch(policy -> policy.method() == HttpMethod.GET + && webRoute.equals(policy.pattern())); + + assertFalse(matchedV1); + assertFalse(matchedWeb); + assertFalse(registry.authorizeApiToken("GET", "/api/v1/namespaces/global/skills/" + "download", Set.of()).allowed()); + assertFalse(registry.authorizeApiToken("GET", "/api/web/namespaces/global/skills/" + "download", Set.of()).allowed()); + } + @Test void apiTokenPolicySupportsNativeCliRoutes() { assertTrue(registry.authorizeApiToken("GET", "/api/cli/v1/auth/whoami", Set.of()).allowed()); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java index 6a62b749..b53a6c63 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java @@ -168,16 +168,20 @@ public class SkillDownloadService { // Only increment download count for PUBLISHED versions if (version.getStatus() == SkillVersionStatus.PUBLISHED) { - skillRepository.incrementDownloadCount(skill.getId()); - skillVersionStatsRepository.incrementDownloadCount(version.getId(), skill.getId()); - eventPublisher.publishEvent(new SkillDownloadedEvent(skill.getId(), version.getId())); + recordPublishedDownload(skill, version); } return result; } + private void recordPublishedDownload(Skill skill, SkillVersion version) { + skillRepository.incrementDownloadCount(skill.getId()); + skillVersionStatsRepository.incrementDownloadCount(version.getId(), skill.getId()); + eventPublisher.publishEvent(new SkillDownloadedEvent(skill.getId(), version.getId())); + } + private DownloadResult buildDownloadResult(Skill skill, SkillVersion version) { - String storageKey = String.format("packages/%d/%d/bundle.zip", skill.getId(), version.getId()); + String storageKey = buildBundleStorageKey(skill, version); DownloadResult result; if (objectStorageService.exists(storageKey)) { @@ -204,6 +208,10 @@ public class SkillDownloadService { return result; } + private String buildBundleStorageKey(Skill skill, SkillVersion version) { + return String.format("packages/%d/%d/bundle.zip", skill.getId(), version.getId()); + } + private DownloadResult buildBundleFromFiles(Skill skill, SkillVersion version) { List files = skillFileRepository.findByVersionId(version.getId()).stream() .filter(file -> objectStorageService.exists(file.getStorageKey())) diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java index 4e8251d1..4bc20d34 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java @@ -6,7 +6,6 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceType; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; -import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.*; import com.iflytek.skillhub.storage.ObjectMetadata; import com.iflytek.skillhub.storage.ObjectStorageService; diff --git a/web/e2e/namespace-search.spec.ts b/web/e2e/namespace-search.spec.ts new file mode 100644 index 00000000..39a48ec1 --- /dev/null +++ b/web/e2e/namespace-search.spec.ts @@ -0,0 +1,106 @@ +import { expect, test, type Page } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' +import { E2eTestDataBuilder } from './helpers/test-data-builder' + +function waitForSkillSearch(page: Page, options: { namespace?: string; q?: string; sort?: string }) { + return page.waitForResponse((response) => { + if (!response.ok() || !response.url().includes('/api/web/skills?')) { + return false + } + + const url = new URL(response.url()) + const namespace = url.searchParams.get('namespace') ?? '' + const query = url.searchParams.get('q') ?? '' + const sort = url.searchParams.get('sort') ?? '' + + return namespace === (options.namespace ?? '') + && query === (options.q ?? '') + && (!options.sort || sort === options.sort) + }) +} + +test.describe('Namespace Search (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + await page.context().setExtraHTTPHeaders({ + 'X-Mock-User-Id': 'local-admin', + }) + }) + + test('submits @namespace keyword search and clears the namespace filter', async ({ page }, testInfo) => { + const builder = new E2eTestDataBuilder(page, testInfo) + await builder.init() + + try { + const namespace = await builder.createNamespace('e2e-pm-search') + const otherNamespace = await builder.createNamespace('e2e-dev-search') + const namespaceSkill = await builder.publishSkill(namespace.slug, { + name: 'roadmap-discovery', + description: 'Roadmap planning skill for namespace search regression.', + }) + const otherSkill = await builder.publishSkill(otherNamespace.slug, { + name: 'roadmap-backend', + description: 'Roadmap planning skill outside the selected namespace.', + }) + await builder.waitForSearchResults('roadmap', [namespaceSkill.slug, otherSkill.slug]) + + await page.goto('/search') + await page.getByPlaceholder('Search skills...').fill(`@${namespace.slug} roadmap`) + + const filteredSearch = waitForSkillSearch(page, { namespace: namespace.slug, q: 'roadmap' }) + await page.getByRole('button', { name: 'Search', exact: true }).click() + await filteredSearch + + await expect(page).toHaveURL(new RegExp(`namespace=${namespace.slug}`)) + await expect(page).toHaveURL(/q=roadmap/) + await expect(page.getByRole('button', { name: `@${namespace.slug}` })).toBeVisible() + await expect(page.getByRole('heading', { name: namespaceSkill.slug })).toBeVisible() + await expect(page.getByText(`@${otherNamespace.slug}`)).toHaveCount(0) + + await page.goto(`/search?q=roadmap&namespace=${namespace.slug}&sort=downloads&page=1&starredOnly=false`) + await expect(page.getByRole('button', { name: `@${namespace.slug}` })).toBeVisible() + + const unfilteredSearch = waitForSkillSearch(page, { q: 'roadmap', sort: 'downloads' }) + await page.getByRole('button', { name: `@${namespace.slug}` }).click() + await unfilteredSearch + + await expect(page).toHaveURL(/q=roadmap/) + await expect(page).toHaveURL(/sort=downloads/) + await expect(page).toHaveURL(/page=0/) + await expect(page).not.toHaveURL(new RegExp(`namespace=${namespace.slug}`)) + await expect(page.getByRole('heading', { name: namespaceSkill.slug })).toBeVisible() + await expect(page.getByRole('heading', { name: otherSkill.slug })).toBeVisible() + } finally { + await builder.cleanup() + } + }) + + test('supports a sixty-four character namespace slug in search input', async ({ page }, testInfo) => { + const builder = new E2eTestDataBuilder(page, testInfo) + await builder.init() + + try { + const namespace = await builder.createNamespace('e2e-namespace-64-slug-search-case-alphaab') + expect(namespace.slug).toHaveLength(64) + const skill = await builder.publishSkill(namespace.slug, { + name: 'boundary-search-agent', + description: 'Boundary namespace search regression skill.', + }) + await builder.waitForSearchResult('boundary', skill.slug) + + await page.goto('/search') + await page.getByPlaceholder('Search skills...').fill(`@${namespace.slug} boundary`) + + const filteredSearch = waitForSkillSearch(page, { namespace: namespace.slug, q: 'boundary' }) + await page.getByRole('button', { name: 'Search', exact: true }).click() + await filteredSearch + + await expect(page).toHaveURL(new RegExp(`namespace=${namespace.slug}`)) + await expect(page).toHaveURL(/q=boundary/) + await expect(page.getByRole('button', { name: `@${namespace.slug}` })).toBeVisible() + await expect(page.getByRole('heading', { name: skill.slug })).toBeVisible() + } finally { + await builder.cleanup() + } + }) +}) diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 5f0a970a..9e056dff 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -916,6 +916,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/web/namespaces/{slug}/transfer-ownership": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["transferOwnership"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/namespaces/{slug}/transfer-ownership": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["transferOwnership_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/web/namespaces/{slug}/restore": { parameters: { query?: never; @@ -3685,6 +3717,21 @@ export interface components { /** Format: int64 */ targetNamespaceId?: number; }; + TransferOwnershipRequest: { + newOwnerId: string; + }; + ApiResponseMessageResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["MessageResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + MessageResponse: { + message?: string; + }; BatchMemberRequest: { members: components["schemas"]["MemberRequest"][]; }; @@ -3781,18 +3828,6 @@ export interface components { AuthorizeRequest: { userCode?: string; }; - ApiResponseMessageResponse: { - /** Format: int32 */ - code?: number; - msg?: string; - data?: components["schemas"]["MessageResponse"]; - /** Format: date-time */ - timestamp?: string; - requestId?: string; - }; - MessageResponse: { - message?: string; - }; SessionBootstrapRequest: { provider: string; }; @@ -3977,8 +4012,8 @@ export interface components { valid?: boolean; errors?: string[]; warnings?: string[]; - resolvedSlug?: string | null; - resolvedVersion?: string | null; + resolvedSlug?: string; + resolvedVersion?: string; }; UpdateProfileRequest: { displayName?: string; @@ -7035,6 +7070,58 @@ export interface operations { }; }; }; + transferOwnership: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TransferOwnershipRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + transferOwnership_1: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TransferOwnershipRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; restoreNamespace: { parameters: { query?: never; diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index b053ca79..d654e026 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -199,9 +199,10 @@ const searchRoute = createRoute({ getParentRoute: () => rootRoute, path: 'search', component: SearchPage, - validateSearch: (search: Record): { q: string; label?: string; sort: string; page: number; starredOnly: boolean } => { + validateSearch: (search: Record): { q: string; namespace?: string; label?: string; sort: string; page: number; starredOnly: boolean } => { return { q: normalizeSearchQuery(typeof search.q === 'string' ? search.q : ''), + namespace: typeof search.namespace === 'string' && search.namespace ? search.namespace.replace(/^@/, '') : undefined, label: typeof search.label === 'string' && search.label ? search.label : undefined, sort: (search.sort as string) || 'newest', page: Number(search.page) || 0, diff --git a/web/src/features/search/search-bar.test.ts b/web/src/features/search/search-bar.test.ts index 199a607d..1ca7ec20 100644 --- a/web/src/features/search/search-bar.test.ts +++ b/web/src/features/search/search-bar.test.ts @@ -3,7 +3,7 @@ import * as mod from './search-bar' /** * search-bar.tsx exports the SearchBar component. The component delegates - * its max-length constraint to the shared MAX_SEARCH_QUERY_LENGTH constant + * its max-length constraint to the shared namespace-aware search input limit * (tested in search-query.test.ts). Controlled/uncontrolled mode logic and * submit/clear handlers are component-internal with no exported helpers. * diff --git a/web/src/features/search/search-bar.tsx b/web/src/features/search/search-bar.tsx index 94be3f25..4978a530 100644 --- a/web/src/features/search/search-bar.tsx +++ b/web/src/features/search/search-bar.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { Loader2, Search, X } from 'lucide-react' -import { MAX_SEARCH_QUERY_LENGTH } from '@/shared/lib/search-query' +import { MAX_SEARCH_INPUT_LENGTH } from '@/shared/lib/search-query' import { Input } from '@/shared/ui/input' import { Button } from '@/shared/ui/button' @@ -59,7 +59,7 @@ export function SearchBar({ defaultValue = '', value, placeholder, isSearching = type="text" value={currentQuery} onChange={(e) => handleChange(e.target.value)} - maxLength={MAX_SEARCH_QUERY_LENGTH} + maxLength={MAX_SEARCH_INPUT_LENGTH} placeholder={placeholder || t('searchBar.placeholder')} className="pl-10 pr-10 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 h-12" /> diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index b63da59f..56039107 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -189,6 +189,7 @@ "noStarredResults": "No starred skills found", "noStarredResultsFor": "No starred skills match \"{{q}}\"", "noStarredSkills": "You have not starred any skills yet", + "namespaceFilter": "@{{namespace}}", "enterKeyword": "Please enter a search keyword", "results": "{{count}} skills found", "resultCount": "Found <1>{{count}} results", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 243ec719..1920b158 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -189,6 +189,7 @@ "noStarredResults": "未找到已收藏技能", "noStarredResultsFor": "已收藏技能中没有与 \"{{q}}\" 相关的结果", "noStarredSkills": "你还没有收藏任何技能", + "namespaceFilter": "@{{namespace}}", "enterKeyword": "请输入搜索关键词", "results": "找到 {{count}} 个技能", "resultCount": "找到 <1>{{count}} 个结果", diff --git a/web/src/pages/dashboard/my-skills.tsx b/web/src/pages/dashboard/my-skills.tsx index 0d90f928..4dbe4765 100644 --- a/web/src/pages/dashboard/my-skills.tsx +++ b/web/src/pages/dashboard/my-skills.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useLocation, useNavigate, useSearch } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { useAuth } from '@/features/auth/use-auth' @@ -64,20 +64,20 @@ export function MySkillsPage() { const [withdrawTarget, setWithdrawTarget] = useState<{ namespace: string; slug: string; name: string; version: string } | null>(null) const [promotionTarget, setPromotionTarget] = useState<{ skillId: number; versionId: number; name: string; version: string } | null>(null) - const updateSearch = (next: Partial, options?: { replace?: boolean }) => { + const updateSearch = useCallback((next: Partial, options?: { replace?: boolean }) => { navigate({ to: '/dashboard/skills', search: (prev) => ({ ...prev, ...next }), replace: options?.replace, }) - } + }, [navigate]) // Push the debounced keyword to the URL (reset page to 0 when search changes) useEffect(() => { if (debouncedKeyword !== keyword) { updateSearch({ q: debouncedKeyword || undefined, page: 0 }, { replace: true }) } - }, [debouncedKeyword]) + }, [debouncedKeyword, keyword, updateSearch]) // Sync keywordInput when navigating back via returnTo useEffect(() => { diff --git a/web/src/pages/namespace.test.tsx b/web/src/pages/namespace.test.tsx index 8fcfd410..c950fb55 100644 --- a/web/src/pages/namespace.test.tsx +++ b/web/src/pages/namespace.test.tsx @@ -1,4 +1,7 @@ -import { describe, expect, it, vi } from 'vitest' +import type { ReactNode } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const buttonRecords: Array<{ label: string }> = [] vi.mock('@tanstack/react-router', () => ({ useNavigate: () => vi.fn(), @@ -23,6 +26,14 @@ vi.mock('@/features/skill/skill-card', () => ({ SkillCard: () => null, })) +vi.mock('@/shared/ui/button', () => ({ + Button: ({ children }: { children?: ReactNode }) => { + const label = Array.isArray(children) ? children.join('') : String(children ?? '') + buttonRecords.push({ label }) + return + }, +})) + vi.mock('@/shared/components/skeleton-loader', () => ({ SkeletonList: () => null, })) @@ -38,7 +49,26 @@ vi.mock('@/shared/hooks/use-namespace-queries', () => ({ vi.mock('@/shared/hooks/use-skill-queries', () => ({ useSearchSkills: () => ({ - data: { items: [] }, + data: { + items: [ + { + id: 1, + displayName: 'Demo Skill', + summary: 'summary', + namespace: 'global', + slug: 'demo', + downloadCount: 1, + starCount: 1, + ratingCount: 0, + updatedAt: '2026-03-20T00:00:00Z', + canSubmitPromotion: false, + publishedVersion: { id: 10, version: '1.0.0', status: 'PUBLISHED' }, + }, + ], + total: 1, + page: 0, + size: 20, + }, isLoading: false, }), })) @@ -47,6 +77,14 @@ import { renderToStaticMarkup } from 'react-dom/server' import { NamespacePage } from './namespace' describe('NamespacePage', () => { + beforeEach(() => { + buttonRecords.length = 0 + useNamespaceDetailMock.mockReturnValue({ + data: { id: 1, slug: 'global', displayName: 'Global', type: 'GLOBAL', status: 'ACTIVE' }, + isLoading: false, + }) + }) + it('exports a named component function', () => { expect(typeof NamespacePage).toBe('function') }) @@ -60,4 +98,11 @@ describe('NamespacePage', () => { const html = renderToStaticMarkup() expect(html).toContain('namespace.notFound') }) + + it('does not render namespace distribution controls when skills are available', () => { + const html = renderToStaticMarkup() + + expect(buttonRecords).toHaveLength(0) + expect(html).not.toContain('type="checkbox"') + }) }) diff --git a/web/src/pages/namespace.tsx b/web/src/pages/namespace.tsx index 69ac0127..275f7ba5 100644 --- a/web/src/pages/namespace.tsx +++ b/web/src/pages/namespace.tsx @@ -63,7 +63,7 @@ export function NamespacePage() { <>
{skillsData.items.map((skill, idx) => ( -
+
handleSkillClick(skill.slug)} diff --git a/web/src/pages/search.test.tsx b/web/src/pages/search.test.tsx index a921d5d3..aac629a0 100644 --- a/web/src/pages/search.test.tsx +++ b/web/src/pages/search.test.tsx @@ -6,6 +6,8 @@ const navigateMock = vi.fn() const useSearchMock = vi.fn() const buttonRecords: Array<{ label: string; variant?: string | null; onClick?: (() => void) | undefined }> = [] const paginationProps: Array<{ onPageChange: (page: number) => void }> = [] +const searchBarProps: Array<{ value?: string; onSearch?: (query: string) => void }> = [] +const searchSkillParams: Array> = [] vi.mock('@tanstack/react-router', () => ({ useNavigate: () => navigateMock, @@ -34,7 +36,10 @@ vi.mock('@/features/auth/use-auth', () => ({ })) vi.mock('@/features/search/search-bar', () => ({ - SearchBar: () =>
search-bar
, + SearchBar: (props: { value?: string; onSearch?: (query: string) => void }) => { + searchBarProps.push(props) + return
search-bar
+ }, })) vi.mock('@/features/skill/skill-card', () => ({ @@ -85,7 +90,10 @@ vi.mock('@/app/page-shell-style', () => ({ const useSearchSkillsMock = vi.fn() vi.mock('@/shared/hooks/use-skill-queries', () => ({ - useSearchSkills: () => useSearchSkillsMock(), + useSearchSkills: (params: Record) => { + searchSkillParams.push(params) + return useSearchSkillsMock() + }, })) vi.mock('@/shared/hooks/use-label-queries', () => ({ @@ -120,8 +128,11 @@ describe('SearchPage', () => { navigateMock.mockReset() buttonRecords.length = 0 paginationProps.length = 0 + searchBarProps.length = 0 + searchSkillParams.length = 0 useSearchMock.mockReturnValue({ q: 'agent', + namespace: 'team-ai', label: 'code-generation', sort: 'downloads', page: 1, @@ -156,6 +167,7 @@ describe('SearchPage', () => { to: '/search', search: { q: 'agent', + namespace: 'team-ai', label: '', sort: 'downloads', page: 0, @@ -173,6 +185,7 @@ describe('SearchPage', () => { to: '/search', search: { q: 'agent', + namespace: 'team-ai', label: 'code-generation', sort: 'newest', page: 0, @@ -191,6 +204,7 @@ describe('SearchPage', () => { to: '/search', search: { q: 'agent', + namespace: 'team-ai', label: 'code-generation', sort: 'downloads', page: 2, @@ -201,6 +215,7 @@ describe('SearchPage', () => { to: '/search', search: { q: 'agent', + namespace: 'team-ai', label: 'code-generation', sort: 'downloads', page: 0, @@ -209,6 +224,38 @@ describe('SearchPage', () => { }) }) + it('passes the namespace URL state into skill search', () => { + renderToStaticMarkup() + + expect(searchSkillParams[0]).toMatchObject({ + q: 'agent', + namespace: 'team-ai', + label: 'code-generation', + sort: 'downloads', + page: 1, + size: 12, + }) + }) + + it('extracts a leading namespace token from the search input', () => { + renderToStaticMarkup() + + searchBarProps[0]?.onSearch?.('@product-team onboarding') + + expect(navigateMock).toHaveBeenCalledWith({ + to: '/search', + search: { + q: 'onboarding', + namespace: 'product-team', + label: 'code-generation', + sort: 'downloads', + page: 0, + starredOnly: false, + }, + replace: true, + }) + }) + it('renders the default skill list when the empty query still returns items', () => { useSearchMock.mockReturnValue({ q: '', diff --git a/web/src/pages/search.tsx b/web/src/pages/search.tsx index a0865909..ec23d319 100644 --- a/web/src/pages/search.tsx +++ b/web/src/pages/search.tsx @@ -12,7 +12,7 @@ import { Pagination } from '@/shared/components/pagination' import { useSearchSkills } from '@/shared/hooks/use-skill-queries' import { useVisibleLabels } from '@/shared/hooks/use-label-queries' import { useMyStars } from '@/shared/hooks/use-user-queries' -import { normalizeSearchQuery } from '@/shared/lib/search-query' +import { formatNamespaceSearchInput, normalizeSearchQuery, parseNamespaceSearchInput } from '@/shared/lib/search-query' import { Button } from '@/shared/ui/button' import { APP_SHELL_PAGE_CLASS_NAME } from '@/app/page-shell-style' @@ -55,17 +55,22 @@ function scrollToTopOnPageChange() { * Search text, sorting, pagination, and the starred-only filter are mirrored into router search * params so the page can be shared, restored, and revisited without losing state. */ -function filterStarredSkills(skills: SkillSummary[], query: string): SkillSummary[] { +function filterStarredSkills(skills: SkillSummary[], query: string, namespace: string): SkillSummary[] { const normalizedQuery = query.trim().toLowerCase() - if (!normalizedQuery) { - return skills - } + const normalizedNamespace = namespace.trim().toLowerCase() - return skills.filter((skill) => - [skill.displayName, skill.summary, skill.namespace, skill.slug] - .filter(Boolean) - .some((value) => value!.toLowerCase().includes(normalizedQuery)) - ) + return skills.filter((skill) => { + const matchesNamespace = !normalizedNamespace || skill.namespace.toLowerCase() === normalizedNamespace + if (!matchesNamespace) { + return false + } + if (!normalizedQuery) { + return true + } + return [skill.displayName, skill.summary, skill.namespace, skill.slug] + .filter(Boolean) + .some((value) => value!.toLowerCase().includes(normalizedQuery)) + }) } function sortStarredSkills(skills: SkillSummary[], sort: string): SkillSummary[] { @@ -86,16 +91,17 @@ export function SearchPage() { const { isAuthenticated } = useAuth() const q = normalizeSearchQuery(searchParams.q || '') + const namespace = (searchParams.namespace || '').replace(/^@/, '') const selectedLabel = searchParams.label || '' const sort = searchParams.sort || 'newest' const page = searchParams.page ?? 0 const starredOnly = searchParams.starredOnly ?? false - const [queryInput, setQueryInput] = useState(q) + const [queryInput, setQueryInput] = useState(formatNamespaceSearchInput(namespace, q)) const previousPageRef = useRef(page) useEffect(() => { - setQueryInput(q) - }, [q]) + setQueryInput(formatNamespaceSearchInput(namespace, q)) + }, [namespace, q]) useEffect(() => { if (previousPageRef.current !== page) { @@ -113,6 +119,7 @@ export function SearchPage() { const { data, isLoading, isFetching } = useSearchSkills({ q, + namespace: namespace || undefined, label: selectedLabel || undefined, sort, page, @@ -128,47 +135,51 @@ export function SearchPage() { useEffect(() => { // Debounce URL updates while the user is typing so query state stays shareable without // triggering a navigation on every keystroke. - const normalizedQuery = normalizeSearchQuery(queryInput) - if (normalizedQuery === q) { + const parsedInput = parseNamespaceSearchInput(queryInput) + if (parsedInput.query === q && parsedInput.namespace === namespace) { return } - if (!normalizedQuery) { + if (!parsedInput.query && !parsedInput.namespace) { startTransition(() => { - navigate({ to: '/search', search: { q: '', label: selectedLabel, sort, page: 0, starredOnly }, replace: page === 0 }) + navigate({ to: '/search', search: { q: '', namespace: '', label: selectedLabel, sort, page: 0, starredOnly }, replace: page === 0 }) }) return } const timeoutId = window.setTimeout(() => { startTransition(() => { - navigate({ to: '/search', search: { q: normalizedQuery, label: selectedLabel, sort, page: 0, starredOnly }, replace: true }) + navigate({ to: '/search', search: { q: parsedInput.query, namespace: parsedInput.namespace, label: selectedLabel, sort, page: 0, starredOnly }, replace: true }) }) }, 250) return () => window.clearTimeout(timeoutId) - }, [navigate, page, q, queryInput, selectedLabel, sort, starredOnly]) + }, [navigate, namespace, page, q, queryInput, selectedLabel, sort, starredOnly]) const handleSearch = (query: string) => { - const normalizedQuery = normalizeSearchQuery(query) + const parsedInput = parseNamespaceSearchInput(query) setQueryInput(query) startTransition(() => { - navigate({ to: '/search', search: { q: normalizedQuery, label: selectedLabel, sort, page: 0, starredOnly }, replace: true }) + navigate({ to: '/search', search: { q: parsedInput.query, namespace: parsedInput.namespace, label: selectedLabel, sort, page: 0, starredOnly }, replace: true }) }) } const handleSortChange = (newSort: string) => { - navigate({ to: '/search', search: { q, label: selectedLabel, sort: newSort, page: 0, starredOnly } }) + navigate({ to: '/search', search: { q, namespace, label: selectedLabel, sort: newSort, page: 0, starredOnly } }) } const handlePageChange = (newPage: number) => { blurActiveElement() - navigate({ to: '/search', search: { q, label: selectedLabel, sort, page: newPage, starredOnly } }) + navigate({ to: '/search', search: { q, namespace, label: selectedLabel, sort, page: newPage, starredOnly } }) } const handleLabelToggle = (label: string) => { const nextLabel = selectedLabel === label ? '' : label - navigate({ to: '/search', search: { q, label: nextLabel, sort, page: 0, starredOnly } }) + navigate({ to: '/search', search: { q, namespace, label: nextLabel, sort, page: 0, starredOnly } }) + } + + const handleNamespaceClear = () => { + navigate({ to: '/search', search: { q, namespace: '', label: selectedLabel, sort, page: 0, starredOnly } }) } const handleStarredToggle = () => { @@ -182,7 +193,7 @@ export function SearchPage() { return } - navigate({ to: '/search', search: { q, label: selectedLabel, sort, page: 0, starredOnly: !starredOnly } }) + navigate({ to: '/search', search: { q, namespace, label: selectedLabel, sort, page: 0, starredOnly: !starredOnly } }) } const handleSkillClick = (namespace: string, slug: string) => { @@ -190,7 +201,7 @@ export function SearchPage() { } const filteredStarredSkills = starredOnly - ? sortStarredSkills(filterStarredSkills(starredSkills ?? [], q), sort) + ? sortStarredSkills(filterStarredSkills(starredSkills ?? [], q, namespace), sort) : [] const starredPageItems = starredOnly ? filteredStarredSkills.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE) @@ -280,6 +291,15 @@ export function SearchPage() { {label.displayName} ))} + {namespace ? ( + + ) : null}
diff --git a/web/src/shared/components/confirm-dialog.tsx b/web/src/shared/components/confirm-dialog.tsx index b760ac45..aee74103 100644 --- a/web/src/shared/components/confirm-dialog.tsx +++ b/web/src/shared/components/confirm-dialog.tsx @@ -45,7 +45,7 @@ export function ConfirmDialog({ return ( - + {title} {description && {description}} diff --git a/web/src/shared/lib/search-query.test.ts b/web/src/shared/lib/search-query.test.ts index b0322df0..5a6cbc86 100644 --- a/web/src/shared/lib/search-query.test.ts +++ b/web/src/shared/lib/search-query.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { MAX_SEARCH_QUERY_LENGTH, normalizeSearchQuery } from './search-query' +import { MAX_SEARCH_QUERY_LENGTH, normalizeSearchQuery, parseNamespaceSearchInput } from './search-query' describe('normalizeSearchQuery', () => { it('trims whitespace around the query', () => { @@ -13,3 +13,36 @@ describe('normalizeSearchQuery', () => { expect(normalizeSearchQuery(query)).toBe('a'.repeat(MAX_SEARCH_QUERY_LENGTH)) }) }) + +describe('parseNamespaceSearchInput', () => { + it('extracts a leading namespace token and keeps the remaining query', () => { + expect(parseNamespaceSearchInput('@team-ai release notes')).toEqual({ + namespace: 'team-ai', + query: 'release notes', + }) + }) + + it('treats a bare namespace token as a namespace-only search', () => { + expect(parseNamespaceSearchInput('@product')).toEqual({ + namespace: 'product', + query: '', + }) + }) + + it('extracts a sixty-four character namespace before limiting the keyword', () => { + const namespace = 'a'.repeat(64) + const query = 'release-notes '.repeat(8) + + expect(parseNamespaceSearchInput(`@${namespace} ${query}`)).toEqual({ + namespace, + query: query.trim().slice(0, MAX_SEARCH_QUERY_LENGTH), + }) + }) + + it('leaves ordinary search text unchanged', () => { + expect(parseNamespaceSearchInput('meeting assistant')).toEqual({ + namespace: '', + query: 'meeting assistant', + }) + }) +}) diff --git a/web/src/shared/lib/search-query.ts b/web/src/shared/lib/search-query.ts index 1b28b3ea..f816ed3f 100644 --- a/web/src/shared/lib/search-query.ts +++ b/web/src/shared/lib/search-query.ts @@ -1,5 +1,36 @@ export const MAX_SEARCH_QUERY_LENGTH = 50 +export const MAX_NAMESPACE_SLUG_LENGTH = 64 +export const MAX_SEARCH_INPUT_LENGTH = MAX_NAMESPACE_SLUG_LENGTH + MAX_SEARCH_QUERY_LENGTH + 2 export function normalizeSearchQuery(query: string): string { return query.trim().slice(0, MAX_SEARCH_QUERY_LENGTH) } + +export interface NamespaceSearchInput { + namespace: string + query: string +} + +const LEADING_NAMESPACE_PATTERN = /^@([a-zA-Z0-9][a-zA-Z0-9-]{0,63})(?:\s+|$)(.*)$/ + +export function parseNamespaceSearchInput(input: string): NamespaceSearchInput { + const trimmed = input.trim() + const match = trimmed.match(LEADING_NAMESPACE_PATTERN) + if (!match) { + return { namespace: '', query: normalizeSearchQuery(trimmed) } + } + + return { + namespace: match[1], + query: normalizeSearchQuery(match[2] ?? ''), + } +} + +export function formatNamespaceSearchInput(namespace: string, query: string): string { + const normalizedNamespace = namespace.trim().replace(/^@/, '') + const normalizedQuery = normalizeSearchQuery(query) + if (!normalizedNamespace) { + return normalizedQuery + } + return normalizedQuery ? `@${normalizedNamespace} ${normalizedQuery}` : `@${normalizedNamespace}` +}