feat(search): add project search support to Cmd+K search

Projects are now searchable alongside issues in the Cmd+K search dialog.
Results are grouped by type (Projects / Issues) with project icon, status,
and description snippet highlighting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jiayuan Zhang
2026-04-10 20:54:19 +08:00
parent 54d452e20d
commit 1a5eb5bd26
8 changed files with 445 additions and 23 deletions

View File

@@ -4,6 +4,7 @@ import type {
UpdateIssueRequest,
ListIssuesResponse,
SearchIssuesResponse,
SearchProjectsResponse,
UpdateMeRequest,
CreateMemberRequest,
UpdateMemberRequest,
@@ -201,6 +202,14 @@ export class ApiClient {
return this.fetch(`/api/issues/search?${search}`, params.signal ? { signal: params.signal } : undefined);
}
async searchProjects(params: { q: string; limit?: number; offset?: number; include_closed?: boolean; signal?: AbortSignal }): Promise<SearchProjectsResponse> {
const search = new URLSearchParams({ q: params.q });
if (params.limit !== undefined) search.set("limit", String(params.limit));
if (params.offset !== undefined) search.set("offset", String(params.offset));
if (params.include_closed) search.set("include_closed", "true");
return this.fetch(`/api/projects/search?${search}`, params.signal ? { signal: params.signal } : undefined);
}
async getIssue(id: string): Promise<Issue> {
return this.fetch(`/api/issues/${id}`);
}

View File

@@ -1,5 +1,6 @@
import type { Issue, IssueStatus, IssuePriority, IssueAssigneeType } from "./issue";
import type { MemberRole } from "./workspace";
import type { Project } from "./project";
// Issue API
export interface CreateIssueRequest {
@@ -57,6 +58,16 @@ export interface SearchIssuesResponse {
total: number;
}
export interface SearchProjectResult extends Project {
match_source: "title" | "description";
matched_snippet?: string;
}
export interface SearchProjectsResponse {
projects: SearchProjectResult[];
total: number;
}
export interface UpdateMeRequest {
name?: string;
avatar_url?: string;

View File

@@ -1,13 +1,15 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Clock, Loader2, MessageSquare, SearchIcon } from "lucide-react";
import { Clock, FolderKanban, Loader2, MessageSquare, SearchIcon } from "lucide-react";
import { Command as CommandPrimitive } from "cmdk";
import type { SearchIssueResult } from "@multica/core/types";
import type { SearchIssueResult, SearchProjectResult } from "@multica/core/types";
import { api } from "@multica/core/api";
import { useRecentIssuesStore } from "@multica/core/issues/stores";
import { StatusIcon } from "../issues/components";
import { STATUS_CONFIG } from "@multica/core/issues/config";
import { PROJECT_STATUS_CONFIG } from "@multica/core/projects/config";
import type { ProjectStatus } from "@multica/core/types";
import {
Dialog,
DialogContent,
@@ -54,17 +56,24 @@ function HighlightText({ text, query }: { text: string; query: string }) {
);
}
interface SearchResults {
issues: SearchIssueResult[];
projects: SearchProjectResult[];
}
export function SearchCommand() {
const { push } = useNavigation();
const open = useSearchStore((s) => s.open);
const setOpen = useSearchStore((s) => s.setOpen);
const recentIssues = useRecentIssuesStore((s) => s.items);
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchIssueResult[]>([]);
const [results, setResults] = useState<SearchResults>({ issues: [], projects: [] });
const [isLoading, setIsLoading] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortRef = useRef<AbortController | null>(null);
const hasResults = results.issues.length > 0 || results.projects.length > 0;
// Global Cmd+K / Ctrl+K shortcut
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -103,7 +112,7 @@ export function SearchCommand() {
useEffect(() => {
if (!open) {
setQuery("");
setResults([]);
setResults({ issues: [], projects: [] });
setIsLoading(false);
}
}, [open]);
@@ -113,7 +122,7 @@ export function SearchCommand() {
if (abortRef.current) abortRef.current.abort();
if (!q.trim()) {
setResults([]);
setResults({ issues: [], projects: [] });
setIsLoading(false);
return;
}
@@ -123,14 +132,25 @@ export function SearchCommand() {
const controller = new AbortController();
abortRef.current = controller;
try {
const res = await api.searchIssues({
q: q.trim(),
limit: 20,
include_closed: true,
signal: controller.signal,
});
const [issueRes, projectRes] = await Promise.all([
api.searchIssues({
q: q.trim(),
limit: 20,
include_closed: true,
signal: controller.signal,
}),
api.searchProjects({
q: q.trim(),
limit: 10,
include_closed: true,
signal: controller.signal,
}),
]);
if (!controller.signal.aborted) {
setResults(res.issues);
setResults({
issues: issueRes.issues,
projects: projectRes.projects,
});
setIsLoading(false);
}
} catch {
@@ -150,11 +170,15 @@ export function SearchCommand() {
);
const handleSelect = useCallback(
(issueId: string) => {
(value: string) => {
setOpen(false);
push(`/issues/${issueId}`);
if (value.startsWith("project:")) {
push(`/projects/${value.slice(8)}`);
} else {
push(`/issues/${value}`);
}
},
[push],
[push, setOpen],
);
return (
@@ -164,9 +188,9 @@ export function SearchCommand() {
showCloseButton={false}
>
<DialogHeader className="sr-only">
<DialogTitle>Search Issues</DialogTitle>
<DialogTitle>Search</DialogTitle>
<DialogDescription>
Search issues by title, description, or comments
Search issues and projects by title or description
</DialogDescription>
</DialogHeader>
<CommandPrimitive
@@ -195,15 +219,59 @@ export function SearchCommand() {
</div>
)}
{!isLoading && query.trim() && results.length === 0 && (
{!isLoading && query.trim() && !hasResults && (
<CommandPrimitive.Empty className="py-10 text-center text-sm text-muted-foreground">
No issues found.
No results found.
</CommandPrimitive.Empty>
)}
{!isLoading && results.length > 0 && (
<CommandPrimitive.Group className="p-2">
{results.map((issue) => (
{!isLoading && results.projects.length > 0 && (
<CommandPrimitive.Group
heading="Projects"
className="p-2 [&_[cmdk-group-heading]]:px-3 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground"
>
{results.projects.map((project) => (
<CommandPrimitive.Item
key={`project:${project.id}`}
value={`project:${project.id}`}
onSelect={handleSelect}
className="flex cursor-default select-none flex-col gap-1 rounded-lg px-3 py-2.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-accent"
>
<div className="flex items-center gap-2.5">
<span className="size-4 shrink-0 text-center text-sm leading-4">
{project.icon || <FolderKanban className="size-4 text-muted-foreground" />}
</span>
<span className="truncate">
<HighlightText text={project.title} query={query} />
</span>
<span
className={`ml-auto text-xs shrink-0 ${PROJECT_STATUS_CONFIG[project.status as ProjectStatus]?.color ?? "text-muted-foreground"}`}
>
{PROJECT_STATUS_CONFIG[project.status as ProjectStatus]?.label ?? project.status}
</span>
</div>
{project.match_source === "description" &&
project.matched_snippet && (
<div className="flex items-start gap-2 pl-[26px]">
<span className="text-xs text-muted-foreground truncate">
<HighlightText
text={project.matched_snippet}
query={query}
/>
</span>
</div>
)}
</CommandPrimitive.Item>
))}
</CommandPrimitive.Group>
)}
{!isLoading && results.issues.length > 0 && (
<CommandPrimitive.Group
heading="Issues"
className="p-2 [&_[cmdk-group-heading]]:px-3 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground"
>
{results.issues.map((issue) => (
<CommandPrimitive.Item
key={issue.id}
value={issue.id}
@@ -277,7 +345,7 @@ export function SearchCommand() {
{!isLoading && !query.trim() && recentIssues.length === 0 && (
<div className="flex flex-col items-center gap-2 py-10 text-sm text-muted-foreground">
<span>Type to search issues...</span>
<span>Type to search issues and projects...</span>
<span className="text-xs">Press <kbd className="rounded bg-muted px-1.5 py-0.5 font-medium">K</kbd> to open this anytime</span>
</div>
)}

View File

@@ -196,6 +196,7 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
// Projects
r.Route("/api/projects", func(r chi.Router) {
r.Get("/search", h.SearchProjects)
r.Get("/", h.ListProjects)
r.Post("/", h.CreateProject)
r.Route("/{id}", func(r chi.Router) {

View File

@@ -2,8 +2,12 @@ package handler
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
@@ -273,3 +277,270 @@ func (h *Handler) DeleteProject(w http.ResponseWriter, r *http.Request) {
h.publish(protocol.EventProjectDeleted, workspaceID, "member", userID, map[string]any{"project_id": id})
w.WriteHeader(http.StatusNoContent)
}
// SearchProjectResponse extends ProjectResponse with search metadata.
type SearchProjectResponse struct {
ProjectResponse
MatchSource string `json:"match_source"`
MatchedSnippet *string `json:"matched_snippet,omitempty"`
}
// buildProjectSearchQuery builds a dynamic SQL query for project search.
func buildProjectSearchQuery(phrase string, terms []string, includeClosed bool) (string, []any) {
phrase = strings.ToLower(phrase)
for i, t := range terms {
terms[i] = strings.ToLower(t)
}
argIdx := 1
args := []any{}
nextArg := func(val any) string {
args = append(args, val)
s := fmt.Sprintf("$%d", argIdx)
argIdx++
return s
}
escapedPhrase := escapeLike(phrase)
phraseParam := nextArg(escapedPhrase)
phraseContains := "'%' || " + phraseParam + " || '%'"
phraseStartsWith := phraseParam + " || '%'"
wsParam := nextArg(nil) // workspace_id placeholder
var termParams []string
if len(terms) > 1 {
for _, t := range terms {
et := escapeLike(t)
termParams = append(termParams, nextArg(et))
}
}
// --- WHERE clause ---
var whereParts []string
// Full phrase match: title or description
phraseMatch := fmt.Sprintf(
"(LOWER(p.title) LIKE %s OR LOWER(COALESCE(p.description, '')) LIKE %s)",
phraseContains, phraseContains,
)
whereParts = append(whereParts, phraseMatch)
// Multi-word AND match
if len(termParams) > 1 {
var termConditions []string
for _, tp := range termParams {
tc := "'%' || " + tp + " || '%'"
termConditions = append(termConditions, fmt.Sprintf(
"(LOWER(p.title) LIKE %s OR LOWER(COALESCE(p.description, '')) LIKE %s)",
tc, tc,
))
}
whereParts = append(whereParts, "("+strings.Join(termConditions, " AND ")+")")
}
whereClause := "(" + strings.Join(whereParts, " OR ") + ")"
if !includeClosed {
whereClause += " AND p.status NOT IN ('completed', 'cancelled')"
}
// --- ORDER BY ranking ---
var rankCases []string
// Tier 0: Exact title match
rankCases = append(rankCases, fmt.Sprintf("WHEN LOWER(p.title) = %s THEN 0", phraseParam))
// Tier 1: Title starts with phrase
rankCases = append(rankCases, fmt.Sprintf("WHEN LOWER(p.title) LIKE %s THEN 1", phraseStartsWith))
// Tier 2: Title contains phrase
rankCases = append(rankCases, fmt.Sprintf("WHEN LOWER(p.title) LIKE %s THEN 2", phraseContains))
// Tier 3: Title matches all words (multi-word only)
if len(termParams) > 1 {
var titleTerms []string
for _, tp := range termParams {
titleTerms = append(titleTerms, fmt.Sprintf("LOWER(p.title) LIKE '%s' || %s || '%s'", "%", tp, "%"))
}
rankCases = append(rankCases, fmt.Sprintf("WHEN (%s) THEN 3", strings.Join(titleTerms, " AND ")))
}
// Tier 4: Description contains phrase
rankCases = append(rankCases, fmt.Sprintf("WHEN LOWER(COALESCE(p.description, '')) LIKE %s THEN 4", phraseContains))
rankExpr := "CASE " + strings.Join(rankCases, " ") + " ELSE 5 END"
// --- match_source expression ---
matchSourceExpr := fmt.Sprintf(`CASE
WHEN LOWER(p.title) LIKE %s THEN 'title'
ELSE 'description'
END`, phraseContains)
if len(termParams) > 1 {
var titleTerms []string
for _, tp := range termParams {
titleTerms = append(titleTerms, fmt.Sprintf("LOWER(p.title) LIKE '%s' || %s || '%s'", "%", tp, "%"))
}
matchSourceExpr = fmt.Sprintf(`CASE
WHEN LOWER(p.title) LIKE %s THEN 'title'
WHEN (%s) THEN 'title'
ELSE 'description'
END`,
phraseContains, strings.Join(titleTerms, " AND "),
)
}
limitParam := nextArg(nil)
offsetParam := nextArg(nil)
query := fmt.Sprintf(`SELECT p.id, p.workspace_id, p.title, p.description, p.icon,
p.status, p.priority, p.lead_type, p.lead_id,
p.created_at, p.updated_at,
COUNT(*) OVER() AS total_count,
%s AS match_source
FROM project p
WHERE p.workspace_id = %s AND %s
ORDER BY %s, p.updated_at DESC
LIMIT %s OFFSET %s`,
matchSourceExpr,
wsParam,
whereClause,
rankExpr,
limitParam,
offsetParam,
)
return query, args
}
func (h *Handler) SearchProjects(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
workspaceID := resolveWorkspaceID(r)
q := r.URL.Query().Get("q")
if q == "" {
writeError(w, http.StatusBadRequest, "q parameter is required")
return
}
limit := 20
offset := 0
if l := r.URL.Query().Get("limit"); l != "" {
if v, err := strconv.Atoi(l); err == nil && v > 0 {
limit = v
}
}
if limit > 50 {
limit = 50
}
if o := r.URL.Query().Get("offset"); o != "" {
if v, err := strconv.Atoi(o); err == nil && v >= 0 {
offset = v
}
}
includeClosed := r.URL.Query().Get("include_closed") == "true"
wsUUID := parseUUID(workspaceID)
terms := splitSearchTerms(q)
sqlQuery, args := buildProjectSearchQuery(q, terms, includeClosed)
args[1] = wsUUID
args[len(args)-2] = limit
args[len(args)-1] = offset
rows, err := h.DB.Query(ctx, sqlQuery, args...)
if err != nil {
slog.Warn("search projects failed", "error", err, "workspace_id", workspaceID, "query", q)
writeError(w, http.StatusInternalServerError, "failed to search projects")
return
}
defer rows.Close()
type projectSearchRow struct {
project db.Project
totalCount int64
matchSource string
}
var results []projectSearchRow
for rows.Next() {
var row projectSearchRow
if err := rows.Scan(
&row.project.ID,
&row.project.WorkspaceID,
&row.project.Title,
&row.project.Description,
&row.project.Icon,
&row.project.Status,
&row.project.Priority,
&row.project.LeadType,
&row.project.LeadID,
&row.project.CreatedAt,
&row.project.UpdatedAt,
&row.totalCount,
&row.matchSource,
); err != nil {
slog.Warn("search projects scan failed", "error", err)
writeError(w, http.StatusInternalServerError, "failed to search projects")
return
}
results = append(results, row)
}
if err := rows.Err(); err != nil {
slog.Warn("search projects rows error", "error", err)
writeError(w, http.StatusInternalServerError, "failed to search projects")
return
}
var total int64
if len(results) > 0 {
total = results[0].totalCount
}
// Batch-fetch issue stats
statsMap := make(map[string]db.GetProjectIssueStatsRow)
if len(results) > 0 {
projectIDs := make([]pgtype.UUID, len(results))
for i, r := range results {
projectIDs[i] = r.project.ID
}
stats, err := h.Queries.GetProjectIssueStats(ctx, projectIDs)
if err == nil {
for _, s := range stats {
statsMap[uuidToString(s.ProjectID)] = s
}
}
}
resp := make([]SearchProjectResponse, len(results))
for i, row := range results {
pr := projectToResponse(row.project)
if s, ok := statsMap[pr.ID]; ok {
pr.IssueCount = s.TotalCount
pr.DoneCount = s.DoneCount
}
spr := SearchProjectResponse{
ProjectResponse: pr,
MatchSource: row.matchSource,
}
if row.matchSource == "description" {
desc := ""
if row.project.Description.Valid {
desc = row.project.Description.String
}
if desc != "" {
snippet := extractSnippet(desc, q)
spr.MatchedSnippet = &snippet
}
}
resp[i] = spr
}
w.Header().Set("X-Total-Count", strconv.FormatInt(total, 10))
writeJSON(w, http.StatusOK, map[string]any{
"projects": resp,
"total": total,
})
}

View File

@@ -93,3 +93,54 @@ func TestBuildSearchQuery_SpecialChars(t *testing.T) {
t.Errorf("expected %% to be escaped in phrase arg, got %q", args[0])
}
}
// --- Project search tests ---
func TestBuildProjectSearchQuery_SingleTerm(t *testing.T) {
query, args := buildProjectSearchQuery("Hello", []string{"Hello"}, false)
if args[0] != "hello" {
t.Errorf("expected phrase arg to be lowercased, got %q", args[0])
}
if strings.Contains(query, "ILIKE") {
t.Error("query should not contain ILIKE")
}
if !strings.Contains(query, "LOWER(p.title) LIKE") {
t.Error("query should contain LOWER(p.title) LIKE")
}
if !strings.Contains(query, "LOWER(COALESCE(p.description, '')) LIKE") {
t.Error("query should contain LOWER(COALESCE(p.description, '')) LIKE")
}
// Should exclude completed/cancelled by default.
if !strings.Contains(query, "NOT IN ('completed', 'cancelled')") {
t.Error("query should exclude completed/cancelled when includeClosed=false")
}
}
func TestBuildProjectSearchQuery_MultiTerm(t *testing.T) {
query, args := buildProjectSearchQuery("Foo Bar", []string{"Foo", "Bar"}, false)
if args[0] != "foo bar" {
t.Errorf("expected phrase arg lowercased, got %q", args[0])
}
if args[2] != "foo" {
t.Errorf("expected first term arg lowercased, got %q", args[2])
}
if args[3] != "bar" {
t.Errorf("expected second term arg lowercased, got %q", args[3])
}
if !strings.Contains(query, " AND ") {
t.Error("multi-word query should contain AND conditions for per-term matching")
}
}
func TestBuildProjectSearchQuery_IncludeClosed(t *testing.T) {
query, _ := buildProjectSearchQuery("test", []string{"test"}, true)
if strings.Contains(query, "NOT IN ('completed', 'cancelled')") {
t.Error("query should not exclude completed/cancelled when includeClosed=true")
}
}

View File

@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_project_title_bigm;
DROP INDEX IF EXISTS idx_project_description_bigm;

View File

@@ -0,0 +1,9 @@
-- Add GIN bigram indexes on project title and description for search.
DO $$
BEGIN
CREATE INDEX idx_project_title_bigm ON project USING gin (LOWER(title) gin_bigm_ops);
CREATE INDEX idx_project_description_bigm ON project USING gin (LOWER(COALESCE(description, '')) gin_bigm_ops);
EXCEPTION WHEN OTHERS THEN
RAISE NOTICE 'skipping bigram indexes on project (pg_bigm not installed)';
END
$$;