From 3833f735412f9027806c531fd774670a78c78534 Mon Sep 17 00:00:00 2001 From: HonLuk Date: Fri, 14 Aug 2026 14:37:53 +0800 Subject: [PATCH 1/3] feat: add issue content search --- apps/api/plane/app/views/search/base.py | 29 +++++--- .../contract/app/test_global_search_app.py | 66 +++++++++++++++++++ .../tests/unit/utils/test_issue_search.py | 28 ++++++++ apps/api/plane/utils/issue_search.py | 29 ++++++++ .../power-k/ui/modal/command-item.tsx | 18 ++++- .../power-k/ui/modal/search-highlight.tsx | 35 ++++++++++ .../power-k/ui/modal/search-menu.tsx | 8 ++- .../power-k/ui/modal/search-results-map.tsx | 56 ++++++++++------ .../power-k/ui/modal/search-results.tsx | 10 ++- packages/types/src/workspace.ts | 1 + 10 files changed, 245 insertions(+), 35 deletions(-) create mode 100644 apps/api/plane/tests/contract/app/test_global_search_app.py create mode 100644 apps/api/plane/tests/unit/utils/test_issue_search.py create mode 100644 apps/web/core/components/power-k/ui/modal/search-highlight.tsx diff --git a/apps/api/plane/app/views/search/base.py b/apps/api/plane/app/views/search/base.py index 289155b87c6..ccb9e10b020 100644 --- a/apps/api/plane/app/views/search/base.py +++ b/apps/api/plane/app/views/search/base.py @@ -41,6 +41,7 @@ ProjectPage, WorkspaceMember, ) +from plane.utils.issue_search import build_search_snippet class GlobalSearchEndpoint(BaseAPIView): @@ -81,7 +82,7 @@ def filter_projects(self, query, slug, _project_id, _workspace_search): ) def filter_issues(self, query, slug, project_id, workspace_search): - fields = ["name", "sequence_id", "project__identifier"] + fields = ["name", "description_stripped", "sequence_id", "project__identifier"] q = Q() if query: for field in fields: @@ -104,14 +105,24 @@ def filter_issues(self, query, slug, project_id, workspace_search): if workspace_search == "false" and project_id: issues = issues.filter(project_id=project_id) - return issues.distinct().values( - "name", - "id", - "sequence_id", - "project__identifier", - "project_id", - "workspace__slug", - )[:100] + issue_results = list( + issues.distinct() + .values( + "name", + "id", + "sequence_id", + "project__identifier", + "project_id", + "workspace__slug", + "description_stripped", + )[:100] + ) + + for issue in issue_results: + description = issue.pop("description_stripped", None) + issue["description_snippet"] = build_search_snippet(description, query) + + return issue_results def filter_cycles(self, query, slug, project_id, workspace_search): fields = ["name"] diff --git a/apps/api/plane/tests/contract/app/test_global_search_app.py b/apps/api/plane/tests/contract/app/test_global_search_app.py new file mode 100644 index 00000000000..29dff167985 --- /dev/null +++ b/apps/api/plane/tests/contract/app/test_global_search_app.py @@ -0,0 +1,66 @@ +"""Contract tests for global workspace search.""" + +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +import pytest +from django.urls import reverse + +from plane.db.models import Issue, Project, ProjectMember + + +@pytest.fixture +def project(db, workspace, create_user): + project = Project.objects.create( + name="Search Project", + identifier="SP", + workspace=workspace, + created_by=create_user, + ) + ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True) + return project + + +@pytest.fixture +def search_issues(db, workspace, project): + title_match = Issue.objects.create( + name="Title needle", + description_html="

Body without the search term.

", + workspace=workspace, + project=project, + ) + description_match = Issue.objects.create( + name="Unrelated title", + description_html="

This body contains the needle in the description.

", + workspace=workspace, + project=project, + ) + no_match = Issue.objects.create( + name="Unrelated issue", + description_html="

Nothing relevant here.

", + workspace=workspace, + project=project, + ) + return title_match, description_match, no_match + + +@pytest.mark.contract +class TestGlobalSearch: + @pytest.mark.django_db + def test_searches_issue_title_or_description(self, session_client, workspace, project, search_issues): + title_match, description_match, no_match = search_issues + response = session_client.get( + reverse("global-search", kwargs={"slug": workspace.slug}), + {"search": "needle", "project_id": str(project.id), "workspace_search": "false"}, + ) + + assert response.status_code == 200 + results = {str(result["id"]): result for result in response.data["results"]["issue"]} + + assert str(title_match.id) in results + assert str(description_match.id) in results + assert str(no_match.id) not in results + assert results[str(title_match.id)]["description_snippet"] is None + assert "needle" in results[str(description_match.id)]["description_snippet"].lower() + assert "

" not in results[str(description_match.id)]["description_snippet"] diff --git a/apps/api/plane/tests/unit/utils/test_issue_search.py b/apps/api/plane/tests/unit/utils/test_issue_search.py new file mode 100644 index 00000000000..f21fa0e7bd8 --- /dev/null +++ b/apps/api/plane/tests/unit/utils/test_issue_search.py @@ -0,0 +1,28 @@ +"""Tests for issue search helpers.""" + +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +import pytest + +from plane.utils.issue_search import build_search_snippet + + +@pytest.mark.unit +class TestBuildSearchSnippet: + def test_returns_none_when_query_is_not_in_content(self): + assert build_search_snippet("A short description", "missing") is None + + def test_returns_normalized_content_when_it_fits(self): + assert build_search_snippet(" Search result\ncontent ", "result") == "Search result content" + + def test_centers_first_match_and_marks_truncated_edges(self): + content = "prefix " + ("x" * 140) + " keyword " + ("y" * 140) + + snippet = build_search_snippet(content, "KEYWORD") + + assert snippet is not None + assert "keyword" in snippet.lower() + assert snippet.startswith("…") + assert snippet.endswith("…") diff --git a/apps/api/plane/utils/issue_search.py b/apps/api/plane/utils/issue_search.py index 7e5fab8fea3..27c422fccc5 100644 --- a/apps/api/plane/utils/issue_search.py +++ b/apps/api/plane/utils/issue_search.py @@ -11,6 +11,35 @@ # Module imports +def build_search_snippet(content, query, max_length=120): + """Return a plain-text snippet centered around the first query match.""" + normalized_content = " ".join((content or "").split()) + normalized_query = " ".join((query or "").split()) + + if not normalized_content or not normalized_query: + return None + + match_index = normalized_content.casefold().find(normalized_query.casefold()) + if match_index == -1: + return None + + snippet_length = max(max_length, len(normalized_query)) + if len(normalized_content) <= snippet_length: + return normalized_content + + context_before = max(0, (snippet_length - len(normalized_query)) // 2) + start_index = max(0, match_index - context_before) + end_index = min(len(normalized_content), start_index + snippet_length) + + # Keep the requested length when the match is close to the end of the text. + if end_index - start_index < snippet_length: + start_index = max(0, end_index - snippet_length) + + prefix = "…" if start_index > 0 else "" + suffix = "…" if end_index < len(normalized_content) else "" + return f"{prefix}{normalized_content[start_index:end_index]}{suffix}" + + def search_issues(query, queryset): fields = ["name", "sequence_id", "project__identifier"] q = Q() diff --git a/apps/web/core/components/power-k/ui/modal/command-item.tsx b/apps/web/core/components/power-k/ui/modal/command-item.tsx index 18fdbd5bc4a..f8ff8399eec 100644 --- a/apps/web/core/components/power-k/ui/modal/command-item.tsx +++ b/apps/web/core/components/power-k/ui/modal/command-item.tsx @@ -17,6 +17,7 @@ type Props = { icon?: React.ComponentType<{ className?: string }>; iconNode?: React.ReactNode; isDisabled?: boolean; + isMultiline?: boolean; isSelected?: boolean; keySequence?: string; label: string | React.ReactNode; @@ -26,12 +27,25 @@ type Props = { }; export function PowerKModalCommandItem(props: Props) { - const { icon: Icon, iconNode, isDisabled, isSelected, keySequence, label, onSelect, shortcut, value } = props; + const { + icon: Icon, + iconNode, + isDisabled, + isMultiline = false, + isSelected, + keySequence, + label, + onSelect, + shortcut, + value, + } = props; return (

diff --git a/apps/web/core/components/power-k/ui/modal/search-highlight.tsx b/apps/web/core/components/power-k/ui/modal/search-highlight.tsx new file mode 100644 index 00000000000..74c35c29eaf --- /dev/null +++ b/apps/web/core/components/power-k/ui/modal/search-highlight.tsx @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Fragment, type ReactNode } from "react"; + +const REGEX_SPECIAL_CHARACTERS = /[.*+?^${}()|[\]\\]/g; + +const escapeRegExp = (value: string) => value.replace(REGEX_SPECIAL_CHARACTERS, "\\$&"); + +export function highlightSearchMatches(text: string, query: string): ReactNode { + const normalizedQuery = query.trim().replace(/\s+/g, " "); + if (!normalizedQuery) return text; + + const queryPattern = new RegExp(`(${escapeRegExp(normalizedQuery)})`, "gi"); + const normalizedQueryLowerCase = normalizedQuery.toLowerCase(); + const partOccurrences = new Map(); + + return text.split(queryPattern).map((part) => { + const isMatch = part.toLowerCase() === normalizedQueryLowerCase; + const occurrence = partOccurrences.get(part) ?? 0; + partOccurrences.set(part, occurrence + 1); + const key = `${part}-${occurrence}`; + + return isMatch ? ( + + {part} + + ) : ( + {part} + ); + }); +} diff --git a/apps/web/core/components/power-k/ui/modal/search-menu.tsx b/apps/web/core/components/power-k/ui/modal/search-menu.tsx index 62fcaaa9581..26b96270ceb 100644 --- a/apps/web/core/components/power-k/ui/modal/search-menu.tsx +++ b/apps/web/core/components/power-k/ui/modal/search-menu.tsx @@ -109,7 +109,13 @@ export function PowerKModalSearchMenu(props: Props) { /> )} - {searchTerm.trim() !== "" && } + {searchTerm.trim() !== "" && ( + + )} ); } diff --git a/apps/web/core/components/power-k/ui/modal/search-results-map.tsx b/apps/web/core/components/power-k/ui/modal/search-results-map.tsx index 2ce27313f6d..d4a07a87c58 100644 --- a/apps/web/core/components/power-k/ui/modal/search-results-map.tsx +++ b/apps/web/core/components/power-k/ui/modal/search-results-map.tsx @@ -18,10 +18,11 @@ import { generateWorkItemLink } from "@plane/utils"; // components import type { TPowerKSearchResultsKeys } from "@/components/power-k/core/types"; import { IssueIdentifier } from "@/components/issues/issue-detail/issue-identifier"; +import { highlightSearchMatches } from "./search-highlight"; export type TPowerKSearchResultGroupDetails = { icon?: React.ComponentType<{ className?: string }>; - itemName: (item: any) => React.ReactNode; + itemName: (item: any, searchTerm: string) => React.ReactNode; path: (item: any, projectId: string | undefined) => string; title: string; }; @@ -29,9 +30,10 @@ export type TPowerKSearchResultGroupDetails = { export const POWER_K_SEARCH_RESULTS_GROUPS_MAP: Record = { cycle: { icon: ContrastIcon, - itemName: (cycle: IWorkspaceDefaultSearchResult) => ( + itemName: (cycle: IWorkspaceDefaultSearchResult, searchTerm: string) => (

- {cycle.project__identifier} {cycle.name} + {cycle.project__identifier}{" "} + {highlightSearchMatches(cycle.name, searchTerm)}

), path: (cycle: IWorkspaceDefaultSearchResult) => @@ -39,16 +41,23 @@ export const POWER_K_SEARCH_RESULTS_GROUPS_MAP: Record ( -
- {" "} - {workItem.name} + itemName: (workItem: IWorkspaceIssueSearchResult, searchTerm: string) => ( +
+
+ + {highlightSearchMatches(workItem.name, searchTerm)} +
+ {workItem.description_snippet && ( +

+ {highlightSearchMatches(workItem.description_snippet, searchTerm)} +

+ )}
), path: (workItem: IWorkspaceIssueSearchResult) => @@ -63,9 +72,10 @@ export const POWER_K_SEARCH_RESULTS_GROUPS_MAP: Record ( + itemName: (view: IWorkspaceDefaultSearchResult, searchTerm: string) => (

- {view.project__identifier} {view.name} + {view.project__identifier}{" "} + {highlightSearchMatches(view.name, searchTerm)}

), path: (view: IWorkspaceDefaultSearchResult) => @@ -74,9 +84,10 @@ export const POWER_K_SEARCH_RESULTS_GROUPS_MAP: Record ( + itemName: (module: IWorkspaceDefaultSearchResult, searchTerm: string) => (

- {module.project__identifier} {module.name} + {module.project__identifier}{" "} + {highlightSearchMatches(module.name, searchTerm)}

), path: (module: IWorkspaceDefaultSearchResult) => @@ -85,9 +96,10 @@ export const POWER_K_SEARCH_RESULTS_GROUPS_MAP: Record ( + itemName: (page: IWorkspacePageSearchResult, searchTerm: string) => (

- {page.project__identifiers?.[0]} {page.name} + {page.project__identifiers?.[0]}{" "} + {highlightSearchMatches(page.name, searchTerm)}

), path: (page: IWorkspacePageSearchResult, projectId: string | undefined) => { @@ -101,13 +113,15 @@ export const POWER_K_SEARCH_RESULTS_GROUPS_MAP: Record project?.name, + itemName: (project: IWorkspaceProjectSearchResult, searchTerm: string) => + highlightSearchMatches(project?.name, searchTerm), path: (project: IWorkspaceProjectSearchResult) => `/${project?.workspace__slug}/projects/${project?.id}/issues/`, title: "Projects", }, workspace: { icon: LayoutGrid, - itemName: (workspace: IWorkspaceSearchResult) => workspace?.name, + itemName: (workspace: IWorkspaceSearchResult, searchTerm: string) => + highlightSearchMatches(workspace?.name, searchTerm), path: (workspace: IWorkspaceSearchResult) => `/${workspace?.slug}/`, title: "Workspaces", }, diff --git a/apps/web/core/components/power-k/ui/modal/search-results.tsx b/apps/web/core/components/power-k/ui/modal/search-results.tsx index eaa5eb9b876..239e7487959 100644 --- a/apps/web/core/components/power-k/ui/modal/search-results.tsx +++ b/apps/web/core/components/power-k/ui/modal/search-results.tsx @@ -18,10 +18,11 @@ import { POWER_K_SEARCH_RESULTS_GROUPS_MAP } from "./search-results-map"; type Props = { closePalette: () => void; results: IWorkspaceSearchResults; + searchTerm: string; }; export const PowerKModalSearchResults = observer(function PowerKModalSearchResults(props: Props) { - const { closePalette, results } = props; + const { closePalette, results, searchTerm } = props; // router const router = useAppRouter(); const { projectId: routerProjectId } = useParams(); @@ -50,10 +51,15 @@ export const PowerKModalSearchResults = observer(function PowerKModalSearchResul value = `${value}-${item.sequence_id}`; } + if ("description_snippet" in item && item.description_snippet) { + value = `${value}-${item.description_snippet}`; + } + return ( { closePalette(); diff --git a/packages/types/src/workspace.ts b/packages/types/src/workspace.ts index fe2a410f61c..28365d53a26 100644 --- a/packages/types/src/workspace.ts +++ b/packages/types/src/workspace.ts @@ -128,6 +128,7 @@ export interface IWorkspaceSearchResult { } export interface IWorkspaceIssueSearchResult { + description_snippet: string | null; id: string; name: string; project__identifier: string; From be4547918e35a73d0c69ba67989eb8af26f71281 Mon Sep 17 00:00:00 2001 From: HonLuk Date: Fri, 14 Aug 2026 14:38:09 +0800 Subject: [PATCH 2/3] perf: support multi-keyword search --- apps/api/plane/app/views/search/base.py | 29 +++++++++---- .../contract/app/test_global_search_app.py | 43 +++++++++++++++++++ .../tests/unit/utils/test_issue_search.py | 13 ++++++ apps/api/plane/utils/issue_search.py | 25 ++++++++--- .../power-k/ui/modal/search-highlight.tsx | 34 +++++++++++++-- .../power-k/ui/modal/search-menu.tsx | 6 ++- .../power-k/ui/modal/search-results-map.tsx | 6 +-- .../power-k/ui/modal/search-results.tsx | 2 +- 8 files changed, 134 insertions(+), 24 deletions(-) diff --git a/apps/api/plane/app/views/search/base.py b/apps/api/plane/app/views/search/base.py index ccb9e10b020..1e2d00c7f29 100644 --- a/apps/api/plane/app/views/search/base.py +++ b/apps/api/plane/app/views/search/base.py @@ -41,7 +41,7 @@ ProjectPage, WorkspaceMember, ) -from plane.utils.issue_search import build_search_snippet +from plane.utils.issue_search import build_search_snippet, split_search_terms class GlobalSearchEndpoint(BaseAPIView): @@ -85,14 +85,25 @@ def filter_issues(self, query, slug, project_id, workspace_search): fields = ["name", "description_stripped", "sequence_id", "project__identifier"] q = Q() if query: - for field in fields: - if field == "sequence_id": - # Match whole integers only (exclude decimal numbers) - sequences = re.findall(r"\b\d+\b", query) - for sequence_id in sequences: - q |= Q(**{"sequence_id": sequence_id}) - else: - q |= Q(**{f"{field}__icontains": query}) + search_terms = split_search_terms(query) + if not search_terms: + q = Q(pk__in=[]) + elif len(search_terms) == 1: + search_term = search_terms[0] + for field in fields: + if field == "sequence_id": + # Match whole integers only (exclude decimal numbers) + sequences = re.findall(r"\b\d+\b", search_term) + for sequence_id in sequences: + q |= Q(**{"sequence_id": sequence_id}) + else: + q |= Q(**{f"{field}__icontains": search_term}) + else: + # For multi-keyword searches, every term must match either the + # work item title or its plain-text description. Sequence IDs + # and project identifiers intentionally retain no new behavior. + for search_term in search_terms: + q &= Q(name__icontains=search_term) | Q(description_stripped__icontains=search_term) issues = Issue.issue_objects.filter( q, diff --git a/apps/api/plane/tests/contract/app/test_global_search_app.py b/apps/api/plane/tests/contract/app/test_global_search_app.py index 29dff167985..c6d6923dd83 100644 --- a/apps/api/plane/tests/contract/app/test_global_search_app.py +++ b/apps/api/plane/tests/contract/app/test_global_search_app.py @@ -45,6 +45,29 @@ def search_issues(db, workspace, project): return title_match, description_match, no_match +@pytest.fixture +def multi_keyword_search_issues(db, workspace, project): + title_and_description_match = Issue.objects.create( + name="Alpha title", + description_html="

Body contains beta.

", + workspace=workspace, + project=project, + ) + description_match = Issue.objects.create( + name="Unrelated title", + description_html="

Alpha and beta are both in the body.

", + workspace=workspace, + project=project, + ) + partial_match = Issue.objects.create( + name="Alpha only", + description_html="

Only alpha appears here.

", + workspace=workspace, + project=project, + ) + return title_and_description_match, description_match, partial_match + + @pytest.mark.contract class TestGlobalSearch: @pytest.mark.django_db @@ -64,3 +87,23 @@ def test_searches_issue_title_or_description(self, session_client, workspace, pr assert results[str(title_match.id)]["description_snippet"] is None assert "needle" in results[str(description_match.id)]["description_snippet"].lower() assert "

" not in results[str(description_match.id)]["description_snippet"] + + @pytest.mark.django_db + def test_requires_all_keywords_across_issue_title_and_description( + self, session_client, workspace, project, multi_keyword_search_issues + ): + title_and_description_match, description_match, partial_match = multi_keyword_search_issues + response = session_client.get( + reverse("global-search", kwargs={"slug": workspace.slug}), + {"search": " alpha beta ", "project_id": str(project.id), "workspace_search": "false"}, + ) + + assert response.status_code == 200 + results = {str(result["id"]): result for result in response.data["results"]["issue"]} + + assert str(title_and_description_match.id) in results + assert str(description_match.id) in results + assert str(partial_match.id) not in results + assert "beta" in results[str(title_and_description_match.id)]["description_snippet"].lower() + assert "alpha" in results[str(description_match.id)]["description_snippet"].lower() + assert "beta" in results[str(description_match.id)]["description_snippet"].lower() diff --git a/apps/api/plane/tests/unit/utils/test_issue_search.py b/apps/api/plane/tests/unit/utils/test_issue_search.py index f21fa0e7bd8..dd6a8101ced 100644 --- a/apps/api/plane/tests/unit/utils/test_issue_search.py +++ b/apps/api/plane/tests/unit/utils/test_issue_search.py @@ -17,6 +17,19 @@ def test_returns_none_when_query_is_not_in_content(self): def test_returns_normalized_content_when_it_fits(self): assert build_search_snippet(" Search result\ncontent ", "result") == "Search result content" + def test_returns_content_when_all_keywords_match_the_description(self): + content = "The alpha keyword and beta keyword are both present." + + assert build_search_snippet(content, " alpha beta ") == content + + def test_centers_on_the_first_matching_keyword(self): + content = "prefix " + ("x" * 140) + " beta " + ("y" * 140) + " alpha" + + snippet = build_search_snippet(content, "alpha beta") + + assert snippet is not None + assert "beta" in snippet.lower() + def test_centers_first_match_and_marks_truncated_edges(self): content = "prefix " + ("x" * 140) + " keyword " + ("y" * 140) diff --git a/apps/api/plane/utils/issue_search.py b/apps/api/plane/utils/issue_search.py index 27c422fccc5..45538b50837 100644 --- a/apps/api/plane/utils/issue_search.py +++ b/apps/api/plane/utils/issue_search.py @@ -11,23 +11,34 @@ # Module imports +def split_search_terms(query): + """Split a search query into non-empty terms using any whitespace.""" + return (query or "").split() + + def build_search_snippet(content, query, max_length=120): - """Return a plain-text snippet centered around the first query match.""" + """Return a plain-text snippet centered around the first matching term.""" normalized_content = " ".join((content or "").split()) - normalized_query = " ".join((query or "").split()) + search_terms = split_search_terms(query) - if not normalized_content or not normalized_query: + if not normalized_content or not search_terms: return None - match_index = normalized_content.casefold().find(normalized_query.casefold()) - if match_index == -1: + normalized_content_casefolded = normalized_content.casefold() + matching_terms = [ + (normalized_content_casefolded.find(term.casefold()), term) + for term in search_terms + ] + matching_terms = [(index, term) for index, term in matching_terms if index >= 0] + if not matching_terms: return None - snippet_length = max(max_length, len(normalized_query)) + match_index, matched_term = min(matching_terms, key=lambda match: match[0]) + snippet_length = max(max_length, len(matched_term)) if len(normalized_content) <= snippet_length: return normalized_content - context_before = max(0, (snippet_length - len(normalized_query)) // 2) + context_before = max(0, (snippet_length - len(matched_term)) // 2) start_index = max(0, match_index - context_before) end_index = min(len(normalized_content), start_index + snippet_length) diff --git a/apps/web/core/components/power-k/ui/modal/search-highlight.tsx b/apps/web/core/components/power-k/ui/modal/search-highlight.tsx index 74c35c29eaf..e396dd7c914 100644 --- a/apps/web/core/components/power-k/ui/modal/search-highlight.tsx +++ b/apps/web/core/components/power-k/ui/modal/search-highlight.tsx @@ -10,6 +10,12 @@ const REGEX_SPECIAL_CHARACTERS = /[.*+?^${}()|[\]\\]/g; const escapeRegExp = (value: string) => value.replace(REGEX_SPECIAL_CHARACTERS, "\\$&"); +const getPartKey = (part: string, partOccurrences: Map) => { + const occurrence = partOccurrences.get(part) ?? 0; + partOccurrences.set(part, occurrence + 1); + return `${part}-${occurrence}`; +}; + export function highlightSearchMatches(text: string, query: string): ReactNode { const normalizedQuery = query.trim().replace(/\s+/g, " "); if (!normalizedQuery) return text; @@ -20,9 +26,31 @@ export function highlightSearchMatches(text: string, query: string): ReactNode { return text.split(queryPattern).map((part) => { const isMatch = part.toLowerCase() === normalizedQueryLowerCase; - const occurrence = partOccurrences.get(part) ?? 0; - partOccurrences.set(part, occurrence + 1); - const key = `${part}-${occurrence}`; + const key = getPartKey(part, partOccurrences); + + return isMatch ? ( + + {part} + + ) : ( + {part} + ); + }); +} + +export function highlightSearchKeywords(text: string, query: string): ReactNode { + const searchTerms = [...new Set(query.trim().split(/\s+/).filter(Boolean))].toSorted( + (firstTerm, secondTerm) => secondTerm.length - firstTerm.length + ); + if (searchTerms.length === 0) return text; + + const queryPattern = new RegExp(`(${searchTerms.map(escapeRegExp).join("|")})`, "gi"); + const normalizedSearchTerms = new Set(searchTerms.map((term) => term.toLowerCase())); + const partOccurrences = new Map(); + + return text.split(queryPattern).map((part) => { + const isMatch = normalizedSearchTerms.has(part.toLowerCase()); + const key = getPartKey(part, partOccurrences); return isMatch ? ( diff --git a/apps/web/core/components/power-k/ui/modal/search-menu.tsx b/apps/web/core/components/power-k/ui/modal/search-menu.tsx index 26b96270ceb..8b9a349896a 100644 --- a/apps/web/core/components/power-k/ui/modal/search-menu.tsx +++ b/apps/web/core/components/power-k/ui/modal/search-menu.tsx @@ -45,8 +45,12 @@ export function PowerKModalSearchMenu(props: Props) { useEffect(() => { if (activePage || !workspaceSlug) return; setIsSearching(true); + setResults(WORKSPACE_DEFAULT_SEARCH_RESULT); + setResultsCount(0); - if (debouncedSearchTerm) { + const hasSearchTerm = debouncedSearchTerm.trim() !== ""; + + if (hasSearchTerm) { workspaceService .searchWorkspace(workspaceSlug.toString(), { ...(projectId ? { project_id: projectId.toString() } : {}), diff --git a/apps/web/core/components/power-k/ui/modal/search-results-map.tsx b/apps/web/core/components/power-k/ui/modal/search-results-map.tsx index d4a07a87c58..1314b9219b4 100644 --- a/apps/web/core/components/power-k/ui/modal/search-results-map.tsx +++ b/apps/web/core/components/power-k/ui/modal/search-results-map.tsx @@ -18,7 +18,7 @@ import { generateWorkItemLink } from "@plane/utils"; // components import type { TPowerKSearchResultsKeys } from "@/components/power-k/core/types"; import { IssueIdentifier } from "@/components/issues/issue-detail/issue-identifier"; -import { highlightSearchMatches } from "./search-highlight"; +import { highlightSearchKeywords, highlightSearchMatches } from "./search-highlight"; export type TPowerKSearchResultGroupDetails = { icon?: React.ComponentType<{ className?: string }>; @@ -51,11 +51,11 @@ export const POWER_K_SEARCH_RESULTS_GROUPS_MAP: Record - {highlightSearchMatches(workItem.name, searchTerm)} + {highlightSearchKeywords(workItem.name, searchTerm)}

{workItem.description_snippet && (

- {highlightSearchMatches(workItem.description_snippet, searchTerm)} + {highlightSearchKeywords(workItem.description_snippet, searchTerm)}

)}
diff --git a/apps/web/core/components/power-k/ui/modal/search-results.tsx b/apps/web/core/components/power-k/ui/modal/search-results.tsx index 239e7487959..f1e5b124c65 100644 --- a/apps/web/core/components/power-k/ui/modal/search-results.tsx +++ b/apps/web/core/components/power-k/ui/modal/search-results.tsx @@ -39,7 +39,7 @@ export const PowerKModalSearchResults = observer(function PowerKModalSearchResul if (section.length <= 0) return null; return ( - + {section.map((item) => { let value = `${key}-${item?.id}-${item.name}`; From c94936cc15202483aa13f0c2efa57cbce8b83f0d Mon Sep 17 00:00:00 2001 From: HonLuk Date: Fri, 14 Aug 2026 15:13:39 +0800 Subject: [PATCH 3/3] fix: address search review feedback --- .../power-k/ui/modal/search-menu.tsx | 25 +++-- .../power-k/ui/modal/search-results-map.tsx | 22 ++++- .../power-k/ui/modal/search-results.tsx | 93 ++++++++++--------- 3 files changed, 86 insertions(+), 54 deletions(-) diff --git a/apps/web/core/components/power-k/ui/modal/search-menu.tsx b/apps/web/core/components/power-k/ui/modal/search-menu.tsx index 8b9a349896a..8ff8cd241be 100644 --- a/apps/web/core/components/power-k/ui/modal/search-menu.tsx +++ b/apps/web/core/components/power-k/ui/modal/search-menu.tsx @@ -44,6 +44,9 @@ export function PowerKModalSearchMenu(props: Props) { useEffect(() => { if (activePage || !workspaceSlug) return; + + let isRequestActive = true; + setIsSearching(true); setResults(WORKSPACE_DEFAULT_SEARCH_RESULT); setResultsCount(0); @@ -57,24 +60,34 @@ export function PowerKModalSearchMenu(props: Props) { search: debouncedSearchTerm, workspace_search: !projectId ? true : isWorkspaceLevel, }) - // oxlint-disable-next-line no-shadow oxlint-disable-next-line promise/always-return - .then((results) => { - setResults(results); - const count = Object.keys(results.results).reduce( - (accumulator, key) => results.results[key as keyof typeof results.results]?.length + accumulator, + .then((nextResults) => { + if (!isRequestActive) return nextResults; + + setResults(nextResults); + const count = Object.keys(nextResults.results).reduce( + (accumulator, key) => nextResults.results[key as keyof typeof nextResults.results]?.length + accumulator, 0 ); setResultsCount(count); + return nextResults; }) .catch(() => { + if (!isRequestActive) return; + setResults(WORKSPACE_DEFAULT_SEARCH_RESULT); setResultsCount(0); }) - .finally(() => setIsSearching(false)); + .finally(() => { + if (isRequestActive) setIsSearching(false); + }); } else { setResults(WORKSPACE_DEFAULT_SEARCH_RESULT); setIsSearching(false); } + + return () => { + isRequestActive = false; + }; }, [debouncedSearchTerm, isWorkspaceLevel, projectId, workspaceSlug, activePage]); if (activePage) return null; diff --git a/apps/web/core/components/power-k/ui/modal/search-results-map.tsx b/apps/web/core/components/power-k/ui/modal/search-results-map.tsx index 1314b9219b4..1e0e6eafebf 100644 --- a/apps/web/core/components/power-k/ui/modal/search-results-map.tsx +++ b/apps/web/core/components/power-k/ui/modal/search-results-map.tsx @@ -20,14 +20,28 @@ import type { TPowerKSearchResultsKeys } from "@/components/power-k/core/types"; import { IssueIdentifier } from "@/components/issues/issue-detail/issue-identifier"; import { highlightSearchKeywords, highlightSearchMatches } from "./search-highlight"; -export type TPowerKSearchResultGroupDetails = { +export type TPowerKSearchResultItemMap = { + workspace: IWorkspaceSearchResult; + project: IWorkspaceProjectSearchResult; + issue: IWorkspaceIssueSearchResult; + cycle: IWorkspaceDefaultSearchResult; + module: IWorkspaceDefaultSearchResult; + issue_view: IWorkspaceDefaultSearchResult; + page: IWorkspacePageSearchResult; +}; + +export type TPowerKSearchResultGroupDetails = { icon?: React.ComponentType<{ className?: string }>; - itemName: (item: any, searchTerm: string) => React.ReactNode; - path: (item: any, projectId: string | undefined) => string; + itemName: (item: TPowerKSearchResultItemMap[TKey], searchTerm: string) => React.ReactNode; + path: (item: TPowerKSearchResultItemMap[TKey], projectId: string | undefined) => string; title: string; }; -export const POWER_K_SEARCH_RESULTS_GROUPS_MAP: Record = { +type TPowerKSearchResultGroupsMap = { + [TKey in TPowerKSearchResultsKeys]: TPowerKSearchResultGroupDetails; +}; + +export const POWER_K_SEARCH_RESULTS_GROUPS_MAP: TPowerKSearchResultGroupsMap = { cycle: { icon: ContrastIcon, itemName: (cycle: IWorkspaceDefaultSearchResult, searchTerm: string) => ( diff --git a/apps/web/core/components/power-k/ui/modal/search-results.tsx b/apps/web/core/components/power-k/ui/modal/search-results.tsx index f1e5b124c65..40806fc7b17 100644 --- a/apps/web/core/components/power-k/ui/modal/search-results.tsx +++ b/apps/web/core/components/power-k/ui/modal/search-results.tsx @@ -13,7 +13,7 @@ import type { IWorkspaceSearchResults } from "@plane/types"; import { useAppRouter } from "@/hooks/use-app-router"; // helpers import { PowerKModalCommandItem } from "./command-item"; -import { POWER_K_SEARCH_RESULTS_GROUPS_MAP } from "./search-results-map"; +import { POWER_K_SEARCH_RESULTS_GROUPS_MAP, type TPowerKSearchResultItemMap } from "./search-results-map"; type Props = { closePalette: () => void; @@ -29,55 +29,60 @@ export const PowerKModalSearchResults = observer(function PowerKModalSearchResul // derived values const projectId = routerProjectId?.toString(); - return ( - <> - {Object.keys(results.results).map((key) => { - const section = results.results[key as keyof typeof results.results]; - const currentSection = POWER_K_SEARCH_RESULTS_GROUPS_MAP[key as keyof typeof POWER_K_SEARCH_RESULTS_GROUPS_MAP]; + const renderSearchResultGroup = ( + key: TKey, + section: TPowerKSearchResultItemMap[TKey][] + ) => { + const currentSection = POWER_K_SEARCH_RESULTS_GROUPS_MAP[key]; + + if (section.length <= 0) return null; - if (!currentSection) return null; - if (section.length <= 0) return null; + return ( + + {section.map((item) => { + let value = `${key}-${item?.id}-${item.name}`; - return ( - - {section.map((item) => { - let value = `${key}-${item?.id}-${item.name}`; + if ("project__identifier" in item) { + value = `${value}-${item.project__identifier}`; + } - if ("project__identifier" in item) { - value = `${value}-${item.project__identifier}`; - } + if ("sequence_id" in item) { + value = `${value}-${item.sequence_id}`; + } - if ("sequence_id" in item) { - value = `${value}-${item.sequence_id}`; - } + if ("description_snippet" in item && item.description_snippet) { + value = `${value}-${item.description_snippet}`; + } - if ("description_snippet" in item && item.description_snippet) { - value = `${value}-${item.description_snippet}`; - } + return ( + { + closePalette(); + router.push(currentSection.path(item, projectId)); + // const itemProjectId = + // item?.project_id || + // (Array.isArray(item?.project_ids) && item?.project_ids?.length > 0 + // ? item?.project_ids[0] + // : undefined); + // if (itemProjectId) openProjectAndScrollToSidebar(itemProjectId); + }} + value={value} + /> + ); + })} + + ); + }; - return ( - { - closePalette(); - router.push(currentSection.path(item, projectId)); - // const itemProjectId = - // item?.project_id || - // (Array.isArray(item?.project_ids) && item?.project_ids?.length > 0 - // ? item?.project_ids[0] - // : undefined); - // if (itemProjectId) openProjectAndScrollToSidebar(itemProjectId); - }} - value={value} - /> - ); - })} - - ); - })} + return ( + <> + {(Object.keys(results.results) as (keyof TPowerKSearchResultItemMap)[]).map((key) => + renderSearchResultGroup(key, results.results[key]) + )} ); });