Skip to content
Clone

feat(search): scope the palette to the page it was opened from

Opening search with cmd-K or "/" from a repository, a channel or a board
nearly always means "look in here", so the palette now inherits the
current location as a default scope instead of silently querying the
whole instance. The scope comes from the route rather than the previous
query, resets whenever the panel reopens or the pathname changes, and is
shown as a removable "This page:" chip - an invisible narrowing is worse
than no narrowing at all. Explicit room:/channel:/board: qualifiers typed
in the box still win over the page default.

Meilisearch gains board_id as a filterable attribute alongside room_id,
and the API takes both as query parameters so the client no longer has to
post-filter hits.

openapi.json, web/openapi.json and the generated globals.d.ts are
regenerated here and therefore also carry the two AI commits before this
one: the new repo_ai toggle fields and the RepoAiSummaryStateDto shape.
ZhenYi committed
parent 860bb09 commit 971c37c Browse files

11 files changed

+142 −10

This change is very large, so some diffs are not shown. Clone the repository to see the full change.

File tree 11
Modified lib/search/src/service.rs View lib/search/src/service.rs
@@ -120,6 +120,13 @@
120120 pub kinds: Option<String>,
121121 pub space_name: Option<String>,
122122 pub repo_name: Option<String>,
123 /// 限定单个看板(工单类命中)与单个频道(消息类命中)。
124 ///
125 /// 全局搜索从仓库页或频道页打开时,界面会把当前所在位置作为默认约束传进来,
126 /// 「在这里搜」和「到处搜」是两种不同意图;把维度做成查询参数,前端就不必在
127 /// 结果里再筛一遍。
128 pub board_id: Option<Uuid>,
129 pub room_id: Option<Uuid>,
123130 pub language: Option<String>,
124131 pub state: Option<String>,
125132 pub limit: Option<usize>,
@@ -383,7 +390,7 @@
383390 self.request(reqwest::Method::PATCH, &format!("/indexes/{index}/settings"))
384391 .json(&json!({
385392 "searchableAttributes": searchable,
386 "filterableAttributes": ["repo_id", "space_id", "space_name", "repo_name", "language", "state", "definition", "room_id"],
393 "filterableAttributes": ["repo_id", "space_id", "space_name", "repo_name", "board_id", "language", "state", "definition", "room_id"],
387394 "displayedAttributes": ["*"],
388395 })),
389396 )
@@ -632,6 +639,8 @@
632639 room_ids: &visible_rooms,
633640 space_name: query.space_name.as_deref(),
634641 repo_name: query.repo_name.as_deref(),
642 board_id: query.board_id.map(|value| value.to_string()),
643 room_id: query.room_id.map(|value| value.to_string()),
635644 language: query.language.as_deref(),
636645 state: query.state.as_deref(),
637646 },
@@ -666,6 +675,8 @@
666675 kinds: Some("symbol".to_string()),
667676 space_name: Some(space_name),
668677 repo_name: Some(repo_name),
678 board_id: None,
679 room_id: None,
669680 language: None,
670681 state: None,
671682 limit,
@@ -691,6 +702,8 @@
691702 kinds: Some("symbol".to_string()),
692703 space_name: Some(space_name),
693704 repo_name: Some(repo_name),
705 board_id: None,
706 room_id: None,
694707 language: None,
695708 state: None,
696709 limit,
@@ -1377,6 +1390,8 @@
13771390 room_ids: &'a [String],
13781391 space_name: Option<&'a str>,
13791392 repo_name: Option<&'a str>,
1393 board_id: Option<String>,
1394 room_id: Option<String>,
13801395 language: Option<&'a str>,
13811396 state: Option<&'a str>,
13821397}
@@ -1406,6 +1421,18 @@
14061421 filters.push(eq_filter(field, value));
14071422 }
14081423 }
1424 // 看板与频道是「当前所在位置」的默认约束,只在对应类型上生效:工单按 board_id,
1425 // 消息按 room_id。其余类型的文档没有这两个字段,加了条件会命中零条。
1426 if kind == SearchKind::Issue
1427 && let Some(board) = options.board_id.as_deref().filter(|value| !value.is_empty())
1428 {
1429 filters.push(eq_filter("board_id", board));
1430 }
1431 if kind == SearchKind::Message
1432 && let Some(room) = options.room_id.as_deref().filter(|value| !value.is_empty())
1433 {
1434 filters.push(eq_filter("room_id", room));
1435 }
14091436 Some(filters.join(" AND "))
14101437}
14111438
Modified web/src/entities/search/model/api.ts View web/src/entities/search/model/api.ts
@@ -8,6 +8,8 @@
88 if (input.kinds?.length) params.set('kinds', input.kinds.join(','))
99 if (input.spaceName) params.set('space_name', input.spaceName)
1010 if (input.repoName) params.set('repo_name', input.repoName)
11 if (input.roomId) params.set('room_id', input.roomId)
12 if (input.boardId) params.set('board_id', input.boardId)
1113 if (input.language) params.set('language', input.language)
1214 if (input.state) params.set('state', input.state)
1315 if (input.limit) params.set('limit', String(input.limit))
Modified web/src/entities/search/model/search.ts View web/src/entities/search/model/search.ts
@@ -36,6 +36,9 @@
3636 kinds?: SearchKind[]
3737 spaceName?: string
3838 repoName?: string
39 /** 限定单个频道(消息类命中)与单个看板(工单类命中)。 */
40 roomId?: string
41 boardId?: string
3942 language?: string
4043 state?: string
4144 limit?: number
Modified web/src/features/search/model/query.ts View web/src/features/search/model/query.ts
@@ -5,6 +5,9 @@
55 text: string
66 spaceName?: string
77 repoName?: string
8 /** `room:` / `board:` 限定符;只影响对应的消息 / 工单类型。 */
9 roomId?: string
10 boardId?: string
811 language?: string
912 state?: string
1013 kinds?: SearchKind[]
@@ -12,7 +15,7 @@
1215
1316/** One qualifier the user typed, rendered as a removable pill. */
1417export type ActiveFilter =
15 | { field: 'repo' | 'language' | 'state'; token: string }
18 | { field: 'repo' | 'room' | 'board' | 'language' | 'state'; token: string }
1619 | { field: 'kind'; kind: SearchKind; token: string }
1720
1821/** Shorthands mapped to the language labels the code index stores. */
@@ -133,6 +136,10 @@
133136 }
134137 } else if (key === 'space' || key === 'org') {
135138 filters.spaceName = value
139 } else if (key === 'room' || key === 'channel') {
140 filters.roomId = value
141 } else if (key === 'board') {
142 filters.boardId = value
136143 } else if (key === 'language' || key === 'lang') {
137144 filters.language = LANGUAGE_ALIASES[lower] ?? value
138145 } else if (key === 'state') {
@@ -157,6 +164,8 @@
157164 if (filters.spaceName || filters.repoName) {
158165 chips.push({ field: 'repo', token: repoToken(filters) })
159166 }
167 if (filters.roomId) chips.push({ field: 'room', token: `room:${filters.roomId}` })
168 if (filters.boardId) chips.push({ field: 'board', token: `board:${filters.boardId}` })
160169 if (filters.language) chips.push({ field: 'language', token: `language:${filters.language}` })
161170 if (filters.state) chips.push({ field: 'state', token: `state:${filters.state}` })
162171 for (const kind of filters.kinds ?? []) {
@@ -170,6 +179,8 @@
170179 const kept: string[] = []
171180 if (filters.text.trim()) kept.push(filters.text.trim())
172181 if (drop.field !== 'repo' && (filters.spaceName || filters.repoName)) kept.push(repoToken(filters))
182 if (drop.field !== 'room' && filters.roomId) kept.push(`room:${filters.roomId}`)
183 if (drop.field !== 'board' && filters.boardId) kept.push(`board:${filters.boardId}`)
173184 if (drop.field !== 'language' && filters.language) kept.push(`language:${filters.language}`)
174185 if (drop.field !== 'state' && filters.state) kept.push(`state:${filters.state}`)
175186 for (const kind of filters.kinds ?? []) {
@@ -177,4 +188,4 @@
177188 kept.push(`type:${kind}`)
178189 }
179190 return kept.join(' ')
180}
No newline at end of file
191}
Modified web/src/features/search/ui/global-search-command.tsx View web/src/features/search/ui/global-search-command.tsx
@@ -1,5 +1,5 @@
11import { useEffect, useMemo, useRef, useState } from 'react'
2import { useNavigate } from 'react-router-dom'
2import { useLocation, useNavigate } from 'react-router-dom'
33import { FileCode2, GitPullRequest, Hash, MessageSquare, Search, X } from 'lucide-react'
44import { useT } from '@/shared/i18n'
55import { search, type SearchHit, type SearchKind } from '@/entities/search'
@@ -19,17 +19,51 @@
1919
2020const KINDS: SearchKind[] = ['code', 'symbol', 'issue', 'pull', 'message']
2121
22export function GlobalSearchCommand() {
22/**
23 * 全局搜索打开时默认继承的「当前所在位置」。
24 *
25 * 从仓库页按 `⌘K` 出发的意图几乎总是「在这个仓库里找」,而不是「在整个实例里
26 * 找」;频道与看板同理。约束来自路由而不是上一轮搜索:命令面板是叠在当前页面
27 * 上的临时层,关掉再开应当重新反映位置,而不是记住上次的输入。
28 *
29 * 一律以 `scope` 形式提供给搜索命令,由组件决定它出现在提示条上、用户可一键去掉,
30 * 这样「限定」和「看得见的限定」是同一件事——悄悄缩小结果范围却不说明,比不限定
31 * 更让人困惑。
32 */
33export interface SearchScope {
34 spaceName?: string
35 repoName?: string
36 roomId?: string
37 boardId?: string
38 /** 用于提示条展示的位置名(仓库名 / 频道名 / 看板名)。 */
39 label?: string
40}
41
42export function GlobalSearchCommand({ scope }: { scope?: SearchScope } = {}) {
2343 const { t } = useT()
2444 const navigate = useNavigate()
45 const { pathname } = useLocation()
2546 const [open, setOpen] = useState(false)
2647 const [query, setQuery] = useState('')
2748 const [selected, setSelected] = useState<SearchKind[]>(KINDS)
2849 const [results, setResults] = useState<SearchHit[]>([])
2950 const [loading, setLoading] = useState(false)
3051 const [error, setError] = useState('')
52 /** 用户是否已手动去掉本次打开默认带上的位置约束。 */
53 const [scopeDropped, setScopeDropped] = useState(false)
3154 const inputRef = useRef<HTMLInputElement | null>(null)
3255
56 // 换页面即重新开始:约束属于「现在在哪」,不是上次搜过什么。
57 useEffect(() => {
58 setScopeDropped(false)
59 }, [pathname])
60
61 // 打开面板时复位,避免上一次的输入与「已移除约束」状态跨次残留。
62 useEffect(() => {
63 if (!open) return
64 setScopeDropped(false)
65 }, [open])
66
3367 useEffect(() => {
3468 const onKeyDown = (event: KeyboardEvent) => {
3569 if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
@@ -62,6 +96,35 @@
6296 const text = filters.text.trim()
6397 const kinds = filters.kinds ?? selected
6498
99 /**
100 * 生效中的作用域:显式写在框里的限定符优先于当前页面。
101 *
102 * 两者都可能存在(在仓库页里敲 `space:other`),此时以用户的显式输入为准——
103 * 他刚刚手动指定了范围。`scopeDropped` 记录用户是否已一键移除了页面带来的约束。
104 */
105 const activeScope = useMemo(() => {
106 if (scopeDropped) return {}
107 return {
108 spaceName: filters.spaceName ?? scope?.spaceName,
109 repoName: filters.repoName ?? scope?.repoName,
110 roomId: filters.roomId ?? scope?.roomId,
111 boardId: filters.boardId ?? scope?.boardId,
112 }
113 }, [scopeDropped, filters.spaceName, filters.repoName, filters.roomId, filters.boardId, scope])
114
115 /** 页面带来的(而非用户输入的)约束,渲染为可移除的提示条。 */
116 const inherited = useMemo(() => {
117 if (scopeDropped || !scope) return null
118 const parts: string[] = []
119 if (!filters.spaceName && !filters.repoName) {
120 if (scope.repoName) parts.push(`${scope.spaceName ?? ''}/${scope.repoName}`.replace(/^\//, ''))
121 else if (scope.spaceName) parts.push(scope.spaceName)
122 }
123 if (!filters.roomId && scope.roomId) parts.push(scope.label ?? scope.roomId)
124 if (!filters.boardId && scope.boardId) parts.push(scope.label ?? scope.boardId)
125 return parts.length ? parts.join(' · ') : null
126 }, [scopeDropped, scope, filters.spaceName, filters.repoName, filters.roomId, filters.boardId])
127
65128 useEffect(() => {
66129 if (!open || !text || kinds.length === 0) {
67130 setResults([])
@@ -76,8 +139,10 @@
76139 search({
77140 q: text,
78141 kinds,
79 spaceName: filters.spaceName,
80 repoName: filters.repoName,
142 spaceName: activeScope.spaceName,
143 repoName: activeScope.repoName,
144 roomId: activeScope.roomId,
145 boardId: activeScope.boardId,
81146 language: filters.language,
82147 state: filters.state,
83148 limit: 20,
@@ -99,7 +164,7 @@
99164 active = false
100165 window.clearTimeout(timer)
101166 }
102 }, [open, text, kinds, filters.spaceName, filters.repoName, filters.language, filters.state, t])
167 }, [open, text, kinds, activeScope, filters.language, filters.state, t])
103168
104169 const toggle = (kind: SearchKind) => {
105170 setSelected(current => current.includes(kind) ? current.filter(value => value !== kind) : [...current, kind])
@@ -196,6 +261,24 @@
196261 ))}
197262 </div>
198263 ) : null}
264 {inherited ? (
265 <div
266 className="flex flex-wrap items-center gap-1.5 border-b border-shell-hairline px-3 py-2"
267 aria-label={t('search.command.scope')}
268 >
269 <span className="text-[11px] text-shell-ink-muted">{t('search.command.scopeFrom')}</span>
270 <button
271 type="button"
272 onClick={() => setScopeDropped(true)}
273 className="flex items-center gap-1 rounded-full border border-shell-hairline bg-shell-field px-2 py-0.5 font-mono text-[11px] text-shell-ink transition-colors hover:bg-shell-active"
274 aria-label={t('search.command.removeScope', { scope: inherited })}
275 title={t('search.command.removeScope', { scope: inherited })}
276 >
277 {inherited}
278 <X className="h-3 w-3 text-shell-ink-muted" aria-hidden />
279 </button>
280 </div>
281 ) : null}
199282 <div className="max-h-[58vh] overflow-y-auto p-2">
200283 {!query.trim() ? <p className="px-3 pb-6 text-center text-[13px] text-shell-ink-muted">{t('search.command.hint')}</p> : null}
201284 {query.trim() && !text ? <p className="px-3 py-8 text-center text-[13px] text-shell-ink-muted">{t('search.command.needText')}</p> : null}
@@ -239,4 +322,4 @@
239322 <span className="shrink-0 rounded bg-shell-active px-1.5 py-0.5 text-[10px] text-shell-ink-muted">{hit.kind}</span>
240323 </button>
241324 )
242}
No newline at end of file
325}
Modified web/src/features/search/index.ts View web/src/features/search/index.ts
@@ -1 +1 @@
1export { GlobalSearchCommand } from './ui/global-search-command'
1export { GlobalSearchCommand, type SearchScope } from './ui/global-search-command'
Modified web/src/shared/api/globals.d.ts View web/src/shared/api/globals.d.ts

Diff too large to display.

Modified web/src/shared/i18n/messages/en/misc.ts View web/src/shared/i18n/messages/en/misc.ts
@@ -54,6 +54,9 @@
5454 'search.command.hint': 'Type a keyword to start searching',
5555 'search.command.needText': 'Add a keyword — the filters are ready',
5656 'search.command.filters': 'Active search filters',
57 'search.command.scope': 'Current scope',
58 'search.command.scopeFrom': 'This page:',
59 'search.command.removeScope': 'Search everything instead (remove {scope})',
5760 'search.command.removeFilter': 'Remove filter {filter}',
5861 'search.command.loading': 'Searching…',
5962 'search.kind.code': 'Code',
Modified web/src/shared/i18n/messages/zh-CN/misc.ts View web/src/shared/i18n/messages/zh-CN/misc.ts
@@ -54,6 +54,9 @@
5454 'search.command.hint': '输入关键词开始搜索',
5555 'search.command.needText': '请再输入关键词,限定条件已生效',
5656 'search.command.filters': '当前搜索条件',
57 'search.command.scope': '当前范围',
58 'search.command.scopeFrom': '当前页面:',
59 'search.command.removeScope': '在全局范围内搜索(移除 {scope})',
5760 'search.command.removeFilter': '移除条件 {filter}',
5861 'search.command.loading': '正在搜索…',
5962 'search.kind.code': '代码',
Modified web/openapi.json View web/openapi.json

Diff too large to display.

Modified openapi.json View openapi.json

Diff too large to display.