diff --git a/packages/core/api/client.ts b/packages/core/api/client.ts index 8f25ded53..26b3ae504 100644 --- a/packages/core/api/client.ts +++ b/packages/core/api/client.ts @@ -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 { + 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 { return this.fetch(`/api/issues/${id}`); } diff --git a/packages/core/types/api.ts b/packages/core/types/api.ts index 102b94604..5ca706264 100644 --- a/packages/core/types/api.ts +++ b/packages/core/types/api.ts @@ -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; diff --git a/packages/views/search/search-command.tsx b/packages/views/search/search-command.tsx index 54729c460..14b05a0d9 100644 --- a/packages/views/search/search-command.tsx +++ b/packages/views/search/search-command.tsx @@ -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([]); + const [results, setResults] = useState({ issues: [], projects: [] }); const [isLoading, setIsLoading] = useState(false); const debounceRef = useRef | null>(null); const abortRef = useRef(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} > - Search Issues + Search - Search issues by title, description, or comments + Search issues and projects by title or description )} - {!isLoading && query.trim() && results.length === 0 && ( + {!isLoading && query.trim() && !hasResults && ( - No issues found. + No results found. )} - {!isLoading && results.length > 0 && ( - - {results.map((issue) => ( + {!isLoading && results.projects.length > 0 && ( + + {results.projects.map((project) => ( + +
+ + {project.icon || } + + + + + + {PROJECT_STATUS_CONFIG[project.status as ProjectStatus]?.label ?? project.status} + +
+ {project.match_source === "description" && + project.matched_snippet && ( +
+ + + +
+ )} +
+ ))} +
+ )} + + {!isLoading && results.issues.length > 0 && ( + + {results.issues.map((issue) => ( - Type to search issues... + Type to search issues and projects... Press ⌘K to open this anytime )} diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index ab56fe773..498f53a3d 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -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) { diff --git a/server/internal/handler/project.go b/server/internal/handler/project.go index 92b96dca6..385734c63 100644 --- a/server/internal/handler/project.go +++ b/server/internal/handler/project.go @@ -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, + }) +} diff --git a/server/internal/handler/search_test.go b/server/internal/handler/search_test.go index 1572e7d38..5f35fbd89 100644 --- a/server/internal/handler/search_test.go +++ b/server/internal/handler/search_test.go @@ -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") + } +} diff --git a/server/migrations/039_project_search_index.down.sql b/server/migrations/039_project_search_index.down.sql new file mode 100644 index 000000000..ee860069d --- /dev/null +++ b/server/migrations/039_project_search_index.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_project_title_bigm; +DROP INDEX IF EXISTS idx_project_description_bigm; diff --git a/server/migrations/039_project_search_index.up.sql b/server/migrations/039_project_search_index.up.sql new file mode 100644 index 000000000..af916568f --- /dev/null +++ b/server/migrations/039_project_search_index.up.sql @@ -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 +$$;