Merge pull request #482 from iflytek/feature/namespace-search-download

feat(skill): add namespace search and bundle download
This commit is contained in:
dongmucat 2026-06-10 20:10:32 +08:00 • committed by GitHub
commit d58c934d64
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 456 additions and 60 deletions

View file

@ -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());

View file

@ -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<SkillFile> files = skillFileRepository.findByVersionId(version.getId()).stream()
.filter(file -> objectStorageService.exists(file.getStorageKey()))

View file

@ -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;

View file

@ -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()
}
})
})

View file

@ -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;

View file

@ -199,9 +199,10 @@ const searchRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'search',
component: SearchPage,
validateSearch: (search: Record<string, unknown>): { q: string; label?: string; sort: string; page: number; starredOnly: boolean } => {
validateSearch: (search: Record<string, unknown>): { 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,

View file

@ -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.
*

View file

@ -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"
/>

View file

@ -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}}</1> results",

View file

@ -189,6 +189,7 @@
"noStarredResults": "未找到已收藏技能",
"noStarredResultsFor": "已收藏技能中没有与 \"{{q}}\" 相关的结果",
"noStarredSkills": "你还没有收藏任何技能",
"namespaceFilter": "@{{namespace}}",
"enterKeyword": "请输入搜索关键词",
"results": "找到 {{count}} 个技能",
"resultCount": "找到 <1>{{count}}</1> 个结果",

View file

@ -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<typeof search>, options?: { replace?: boolean }) => {
const updateSearch = useCallback((next: Partial<typeof search>, 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(() => {

View file

@ -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 <button>{children}</button>
},
}))
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(<NamespacePage />)
expect(html).toContain('namespace.notFound')
})
it('does not render namespace distribution controls when skills are available', () => {
const html = renderToStaticMarkup(<NamespacePage />)
expect(buttonRecords).toHaveLength(0)
expect(html).not.toContain('type="checkbox"')
})
})

View file

@ -63,7 +63,7 @@ export function NamespacePage() {
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{skillsData.items.map((skill, idx) => (
<div key={skill.id} className={`animate-fade-up delay-${Math.min(idx + 1, 6)}`}>
<div key={skill.id} className={`relative animate-fade-up delay-${Math.min(idx + 1, 6)}`}>
<SkillCard
skill={skill}
onClick={() => handleSkillClick(skill.slug)}

View file

@ -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<Record<string, unknown>> = []
vi.mock('@tanstack/react-router', () => ({
useNavigate: () => navigateMock,
@ -34,7 +36,10 @@ vi.mock('@/features/auth/use-auth', () => ({
}))
vi.mock('@/features/search/search-bar', () => ({
SearchBar: () => <div>search-bar</div>,
SearchBar: (props: { value?: string; onSearch?: (query: string) => void }) => {
searchBarProps.push(props)
return <div>search-bar</div>
},
}))
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<string, unknown>) => {
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(<SearchPage />)
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(<SearchPage />)
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: '',

View file

@ -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}
</Button>
))}
{namespace ? (
<Button
variant="default"
size="sm"
onClick={handleNamespaceClear}
>
{t('search.namespaceFilter', { namespace })}
</Button>
) : null}
</div>
</div>

View file

@ -45,7 +45,7 @@ export function ConfirmDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent data-testid={contentTestId}>
<DialogContent data-testid={contentTestId} aria-label={title}>
<DialogHeader className="min-w-0 text-center sm:text-center">
<DialogTitle className="text-center">{title}</DialogTitle>
{description && <DialogDescription className="text-center break-all">{description}</DialogDescription>}

View file

@ -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',
})
})
})

View file

@ -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}`
}