Merge pull request #307 from multica-ai/feat/global-search

feat: add global issue search
This commit is contained in:
Naiyuan Qing 2026-04-01 22:13:17 +08:00 committed by GitHub
commit 59ebf30cf0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 180 additions and 11 deletions

View file

@ -15,6 +15,7 @@ import {
BookOpenText,
SquarePen,
CircleUser,
Search,
} from "lucide-react";
import { WorkspaceAvatar } from "@/features/workspace";
import { useIssueDraftStore } from "@/features/issues/stores/draft-store";
@ -155,16 +156,27 @@ export function AppSidebar() {
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
<Tooltip>
<TooltipTrigger
className="relative flex h-7 w-7 items-center justify-center rounded-lg bg-background text-foreground shadow-sm hover:bg-accent"
onClick={() => useModalStore.getState().open("create-issue")}
>
<SquarePen className="size-3.5" />
<DraftDot />
</TooltipTrigger>
<TooltipContent side="bottom">New issue</TooltipContent>
</Tooltip>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger
className="flex h-7 w-7 items-center justify-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground"
onClick={() => useModalStore.getState().open("search-issues")}
>
<Search className="size-3.5" />
</TooltipTrigger>
<TooltipContent side="bottom">Search issues</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
className="relative flex h-7 w-7 items-center justify-center rounded-lg bg-background text-foreground shadow-sm hover:bg-accent"
onClick={() => useModalStore.getState().open("create-issue")}
>
<SquarePen className="size-3.5" />
<DraftDot />
</TooltipTrigger>
<TooltipContent side="bottom">New issue</TooltipContent>
</Tooltip>
</div>
</div>
</SidebarHeader>

View file

@ -3,6 +3,7 @@
import { useModalStore } from "./store";
import { CreateWorkspaceModal } from "./create-workspace";
import { CreateIssueModal } from "./create-issue";
import { SearchIssuesModal } from "./search-issues";
export function ModalRegistry() {
const modal = useModalStore((s) => s.modal);
@ -14,6 +15,8 @@ export function ModalRegistry() {
return <CreateWorkspaceModal onClose={close} />;
case "create-issue":
return <CreateIssueModal onClose={close} data={data} />;
case "search-issues":
return <SearchIssuesModal onClose={close} />;
default:
return null;
}

View file

@ -0,0 +1,143 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter } from "next/navigation";
import {
CommandDialog,
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
} from "@/components/ui/command";
import { Skeleton } from "@/components/ui/skeleton";
import { StatusIcon } from "@/features/issues/components";
import { api } from "@/shared/api";
import type { Issue } from "@/shared/types";
function SearchSkeleton() {
return (
<div className="p-2 space-y-1">
{Array.from({ length: 5 }, (_, i) => (
<div key={i} className="flex items-center gap-2 px-2 py-1.5">
<Skeleton className="size-4 shrink-0 rounded-full" />
<Skeleton className="h-4 flex-1" />
<Skeleton className="h-3.5 w-12" />
</div>
))}
</div>
);
}
export function SearchIssuesModal({ onClose }: { onClose: () => void }) {
const router = useRouter();
const [query, setQuery] = useState("");
const [results, setResults] = useState<Issue[]>([]);
const [loading, setLoading] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
const composingRef = useRef(false);
const search = useCallback(async (q: string) => {
if (!q.trim()) {
setResults([]);
setLoading(false);
return;
}
setLoading(true);
try {
const res = await api.listIssues({ search: q.trim(), limit: 20 });
setResults(res.issues);
} catch {
setResults([]);
} finally {
setLoading(false);
}
}, []);
const scheduleSearch = useCallback(
(q: string) => {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (q.trim()) setLoading(true);
debounceRef.current = setTimeout(() => search(q), 300);
},
[search]
);
useEffect(() => {
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
const handleValueChange = (value: string) => {
setQuery(value);
if (!composingRef.current) {
scheduleSearch(value);
}
};
const handleCompositionStart = () => {
composingRef.current = true;
};
const handleCompositionEnd = (e: React.CompositionEvent<HTMLInputElement>) => {
composingRef.current = false;
scheduleSearch((e.target as HTMLInputElement).value);
};
const handleSelect = (issue: Issue) => {
onClose();
router.push(`/issues/${issue.id}`);
};
return (
<CommandDialog
open
onOpenChange={(open) => {
if (!open) onClose();
}}
title="Search Issues"
description="Search issues by title"
className="top-[min(33%,12rem)]"
>
<Command shouldFilter={false}>
<CommandInput
placeholder="Search issues..."
value={query}
onValueChange={handleValueChange}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
/>
<CommandList className="max-h-[min(18rem,calc(100dvh-10rem))]">
{!query.trim() ? (
<div className="py-6 text-center text-sm text-muted-foreground">
Type to search issues by title
</div>
) : loading ? (
<SearchSkeleton />
) : (
<>
<CommandEmpty>No issues found</CommandEmpty>
<CommandGroup>
{results.map((issue) => (
<CommandItem
key={issue.id}
value={issue.id}
onSelect={() => handleSelect(issue)}
>
<StatusIcon status={issue.status} className="h-4 w-4" />
<span className="flex-1 truncate">{issue.title}</span>
<span className="text-xs text-muted-foreground">
{issue.identifier}
</span>
</CommandItem>
))}
</CommandGroup>
</>
)}
</CommandList>
</Command>
</CommandDialog>
);
}

View file

@ -2,7 +2,7 @@
import { create } from "zustand";
type ModalType = "create-workspace" | "create-issue" | null;
type ModalType = "create-workspace" | "create-issue" | "search-issues" | null;
interface ModalStore {
modal: ModalType;

View file

@ -163,6 +163,7 @@ export class ApiClient {
if (params?.status) search.set("status", params.status);
if (params?.priority) search.set("priority", params.priority);
if (params?.assignee_id) search.set("assignee_id", params.assignee_id);
if (params?.search) search.set("search", params.search);
return this.fetch(`/api/issues?${search}`);
}

View file

@ -31,6 +31,7 @@ export interface ListIssuesParams {
status?: IssueStatus;
priority?: IssuePriority;
assignee_id?: string;
search?: string;
}
export interface ListIssuesResponse {

View file

@ -99,6 +99,10 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
if a := r.URL.Query().Get("assignee_id"); a != "" {
assigneeFilter = parseUUID(a)
}
var searchFilter pgtype.Text
if s := r.URL.Query().Get("search"); s != "" {
searchFilter = pgtype.Text{String: s, Valid: true}
}
issues, err := h.Queries.ListIssues(ctx, db.ListIssuesParams{
WorkspaceID: parseUUID(workspaceID),
@ -107,6 +111,7 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
Status: statusFilter,
Priority: priorityFilter,
AssigneeID: assigneeFilter,
Search: searchFilter,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list issues")

View file

@ -195,6 +195,7 @@ WHERE workspace_id = $1
AND ($4::text IS NULL OR status = $4)
AND ($5::text IS NULL OR priority = $5)
AND ($6::uuid IS NULL OR assignee_id = $6)
AND ($7::text IS NULL OR title ILIKE '%' || $7 || '%')
ORDER BY position ASC, created_at DESC
LIMIT $2 OFFSET $3
`
@ -206,6 +207,7 @@ type ListIssuesParams struct {
Status pgtype.Text `json:"status"`
Priority pgtype.Text `json:"priority"`
AssigneeID pgtype.UUID `json:"assignee_id"`
Search pgtype.Text `json:"search"`
}
func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]Issue, error) {
@ -216,6 +218,7 @@ func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]Issue
arg.Status,
arg.Priority,
arg.AssigneeID,
arg.Search,
)
if err != nil {
return nil, err

View file

@ -4,6 +4,7 @@ WHERE workspace_id = $1
AND (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status'))
AND (sqlc.narg('priority')::text IS NULL OR priority = sqlc.narg('priority'))
AND (sqlc.narg('assignee_id')::uuid IS NULL OR assignee_id = sqlc.narg('assignee_id'))
AND (sqlc.narg('search')::text IS NULL OR title ILIKE '%' || sqlc.narg('search') || '%')
ORDER BY position ASC, created_at DESC
LIMIT $2 OFFSET $3;