"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Check, Clock, Copy, FileText, Link2, Loader2, MessageSquare, Plus, SearchIcon, ListChevronsDownUp, ListChevronsUpDown, Monitor, Moon, Sun, type LucideIcon, } from "lucide-react"; import { Command as CommandPrimitive } from "cmdk"; import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import type { MemberWithUser, SearchIssueResult, SearchProjectResult, } from "@multica/core/types"; import { api } from "@multica/core/api"; import { openCreateIssueWithPreference, selectRecentIssues, useCommentCollapseStore, useRecentIssuesStore, useResolvedExpandStore, } from "@multica/core/issues/stores"; import { issueDetailOptions, issueTimelineOptions } from "@multica/core/issues/queries"; import { useWorkspaceId } from "@multica/core"; import { useWorkspacePaths } from "@multica/core/paths"; import type { WorkspacePaths } from "@multica/core/paths"; import { useModalStore } from "@multica/core/modals"; import { createShortcutChord } from "@multica/core/shortcuts"; import { memberListOptions } from "@multica/core/workspace/queries"; import { resolvePublicFileUrl } from "@multica/core/workspace/avatar-url"; import { StatusIcon } from "../issues/components"; import { resolvedThreadRootIds, rootCommentIds } from "../issues/components/thread-utils"; import { ProjectIcon } from "../projects/components/project-icon"; import { routeIconForPath } from "../layout/route-icon-components"; import { PROJECT_STATUS_CONFIG } from "@multica/core/projects/config"; import type { ProjectStatus } from "@multica/core/types"; import { ActorAvatar } from "../common/actor-avatar"; import { ShortcutKeycaps } from "../common/shortcut-keycaps"; import { ActorAvatar as ActorAvatarBase } from "@multica/ui/components/common/actor-avatar"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from "@multica/ui/components/ui/dialog"; import { useTheme } from "@multica/ui/components/common/theme-provider"; import { copyText } from "@multica/ui/lib/clipboard"; import { useNavigation } from "../navigation"; import { useT } from "../i18n"; import { matchesPinyin } from "../editor/extensions/pinyin-match"; import { HighlightText } from "./highlight-text"; import { useSearchStore } from "./search-store"; // Nav items reference WorkspacePaths method names so they can be resolved // against the current workspace slug at render time (see SearchCommand body). // Only parameterless paths are valid nav destinations. type NavKey = | "inbox" | "myIssues" | "issues" | "projects" | "agents" | "runtimes" | "skills" | "settings"; // No `icon` field: like the sidebar nav, a page's icon is derived from its // destination path via routeIconForPath, so all navigation surfaces show the // same icon for the same route. interface NavPage { key: NavKey; label: string; keywords: string[]; } type ThemeValue = "light" | "dark" | "system"; function memberInitials(name: string) { return name .split(" ") .map((word) => word[0]) .join("") .toUpperCase() .slice(0, 2); } function matchesMember(member: MemberWithUser, query: string) { return ( member.name.toLowerCase().includes(query) || member.email.toLowerCase().includes(query) || (query.length >= 3 && member.role.startsWith(query)) || matchesPinyin(member.name, query) ); } function IssueAssigneeAvatar({ assigneeType, assigneeId, }: { assigneeType?: string | null; assigneeId?: string | null; }) { if (!assigneeType || !assigneeId) return null; return ( ); } interface CommandItem { key: string; label: string; icon: LucideIcon; keywords: string[]; trailing?: React.ReactNode; onSelect: () => void; } interface SearchResults { issues: SearchIssueResult[]; projects: SearchProjectResult[]; } export function SearchCommand() { const { t } = useT("search"); const navPages: NavPage[] = [ { key: "inbox", label: t(($) => $.pages.inbox), keywords: ["inbox", "notifications", "收件箱"] }, { key: "myIssues", label: t(($) => $.pages.my_issues), keywords: ["my", "issues", "assigned", "我的"] }, { key: "issues", label: t(($) => $.pages.issues), keywords: ["issues", "tasks", "bugs"] }, { key: "projects", label: t(($) => $.pages.projects), keywords: ["projects", "kanban", "项目"] }, { key: "agents", label: t(($) => $.pages.agents), keywords: ["agents", "bots", "ai"] }, { key: "runtimes", label: t(($) => $.pages.runtimes), keywords: ["runtimes", "environments"] }, { key: "skills", label: t(($) => $.pages.skills), keywords: ["skills", "library"] }, { key: "settings", label: t(($) => $.pages.settings), keywords: ["settings", "config", "preferences", "设置"] }, ]; const { push, pathname, getShareableUrl } = useNavigation(); const open = useSearchStore((s) => s.open); const setOpen = useSearchStore((s) => s.setOpen); const wsId = useWorkspaceId(); const recentItems = useRecentIssuesStore(selectRecentIssues(wsId)); const p: WorkspacePaths = useWorkspacePaths(); const { theme, setTheme } = useTheme(); const { data: members = [] } = useQuery(memberListOptions(wsId)); // Resolve each recent issue via its cached detail entry. Recent items are // typically already in the detail cache because the user has opened them; // if not, this triggers a lookup per id so Recent never depends on whether // the issue falls inside the paginated list cache. const recentDetailQueries = useQueries({ queries: recentItems.map((item) => issueDetailOptions(wsId, item.id)), }); const recentIssues = useMemo( () => recentDetailQueries.flatMap((q) => (q.data ? [q.data] : [])), [recentDetailQueries], ); const [query, setQuery] = useState(""); const [results, setResults] = useState({ issues: [], projects: [] }); const [isLoading, setIsLoading] = useState(false); const debounceRef = useRef | null>(null); const abortRef = useRef(null); const filteredPages = useMemo(() => { const q = query.trim().toLowerCase(); if (!q) return []; return navPages.filter( (page) => page.label.toLowerCase().includes(q) || page.keywords.some((kw) => kw.includes(q)), ); }, [query]); // Detect if current route is an issue detail page — /{slug}/issues/{id}. // Falls back to null on any other route; used to gate issue-specific commands. const currentIssueId = useMemo(() => { const match = pathname.match(/\/issues\/([^/]+)$/); const raw = match?.[1]; return raw ? decodeURIComponent(raw) : null; }, [pathname]); const { data: currentIssue = null } = useQuery({ ...issueDetailOptions(wsId, currentIssueId ?? ""), enabled: !!currentIssueId, }); const queryClient = useQueryClient(); const commands = useMemo(() => { const activeThemeCheck = (value: ThemeValue) => theme === value ? ( $.commands.current_theme_aria)} className="ml-auto size-4 shrink-0 text-muted-foreground" /> ) : undefined; const items: CommandItem[] = [ { key: "new-issue", label: t(($) => $.commands.new_issue), icon: Plus, keywords: ["new", "issue", "create", "add"], onSelect: () => { openCreateIssueWithPreference(); setOpen(false); }, }, { key: "new-project", label: t(($) => $.commands.new_project), icon: Plus, keywords: ["new", "project", "create", "add"], onSelect: () => { useModalStore.getState().open("create-project"); setOpen(false); }, }, ]; if (currentIssueId && currentIssue) { const identifier = currentIssue.identifier; items.push( { key: "copy-issue-link", label: t(($) => $.commands.copy_issue_link), icon: Link2, keywords: ["copy", "link", "share", "url", identifier.toLowerCase()], onSelect: () => { void copyText(getShareableUrl(pathname)).then((ok) => { if (ok) toast.success(t(($) => $.toast.link_copied)); }); setOpen(false); }, }, { key: "copy-issue-identifier", label: t(($) => $.commands.copy_identifier, { identifier }), icon: Copy, keywords: ["copy", "id", "identifier", identifier.toLowerCase()], onSelect: () => { void copyText(identifier).then((ok) => { if (ok) toast.success(t(($) => $.toast.copied_identifier, { identifier })); }); setOpen(false); }, }, { key: "fold-all-comments", label: t(($) => $.commands.fold_all_comments), icon: ListChevronsDownUp, keywords: ["fold", "collapse", "comments", "收起", "折叠", "评论"], onSelect: () => { // The timeline is already cached whenever the issue page has // rendered; ensureQueryData only fetches on a cold cache. If it // still can't load, no comments are on screen — dropping the // action matches the visible state. void queryClient .ensureQueryData(issueTimelineOptions(currentIssueId)) .then((entries) => { useCommentCollapseStore .getState() .collapseAll(currentIssueId, rootCommentIds(entries)); useResolvedExpandStore.getState().collapseAll(currentIssueId); }) .catch(() => {}); setOpen(false); }, }, { key: "unfold-all-comments", label: t(($) => $.commands.unfold_all_comments), icon: ListChevronsUpDown, keywords: ["unfold", "expand", "comments", "展开", "评论"], onSelect: () => { void queryClient .ensureQueryData(issueTimelineOptions(currentIssueId)) .then((entries) => { useCommentCollapseStore.getState().expandAll(currentIssueId); useResolvedExpandStore .getState() .expandAll(currentIssueId, resolvedThreadRootIds(entries)); }) .catch(() => {}); setOpen(false); }, }, ); } items.push( { key: "theme-light", label: t(($) => $.commands.switch_to_light), icon: Sun, keywords: ["light", "theme", "appearance", "mode", "bright"], trailing: activeThemeCheck("light"), onSelect: () => { setTheme("light"); setOpen(false); }, }, { key: "theme-dark", label: t(($) => $.commands.switch_to_dark), icon: Moon, keywords: ["dark", "theme", "appearance", "mode", "night"], trailing: activeThemeCheck("dark"), onSelect: () => { setTheme("dark"); setOpen(false); }, }, { key: "theme-system", label: t(($) => $.commands.use_system_theme), icon: Monitor, keywords: ["system", "theme", "appearance", "mode", "auto"], trailing: activeThemeCheck("system"), onSelect: () => { setTheme("system"); setOpen(false); }, }, ); return items; }, [currentIssue, currentIssueId, getShareableUrl, pathname, queryClient, setOpen, setTheme, theme, t]); const filteredCommands = useMemo(() => { const q = query.trim().toLowerCase(); // No query: only surface the primary creation action. Other commands // (theme switches, copy actions, New Project) are revealed as the user // types, leaving the empty-state space to Recent. if (!q) return commands.filter((c) => c.key === "new-issue"); return commands.filter( (c) => c.label.toLowerCase().includes(q) || c.keywords.some((kw) => kw.includes(q)), ); }, [commands, query]); const filteredMembers = useMemo(() => { const q = query.trim().toLowerCase(); if (!q) return []; const wantsAllMembers = q.length >= 3 && ("members".startsWith(q) || "people".startsWith(q) || "users".startsWith(q) || "team".startsWith(q)); return members .filter((member) => wantsAllMembers || matchesMember(member, q)) .slice(0, 10); }, [members, query]); const hasResults = results.issues.length > 0 || results.projects.length > 0 || filteredMembers.length > 0; // Close on single ESC — capture phase fires before base-ui Dialog's handlers useEffect(() => { if (!open) return; const handleEsc = (e: KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); setOpen(false); } }; document.addEventListener("keydown", handleEsc, true); return () => document.removeEventListener("keydown", handleEsc, true); }, [open, setOpen]); // Cleanup debounce/abort on unmount useEffect(() => { return () => { if (debounceRef.current) clearTimeout(debounceRef.current); if (abortRef.current) abortRef.current.abort(); }; }, []); // Reset state when dialog closes useEffect(() => { if (!open) { setQuery(""); setResults({ issues: [], projects: [] }); setIsLoading(false); } }, [open]); const search = useCallback((q: string) => { if (debounceRef.current) clearTimeout(debounceRef.current); if (abortRef.current) abortRef.current.abort(); if (!q.trim()) { setResults({ issues: [], projects: [] }); setIsLoading(false); return; } setIsLoading(true); debounceRef.current = setTimeout(async () => { const controller = new AbortController(); abortRef.current = controller; try { 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({ issues: issueRes.issues, projects: projectRes.projects, }); setIsLoading(false); } } catch { if (!controller.signal.aborted) { setIsLoading(false); } } }, 300); }, []); const handleValueChange = useCallback( (value: string) => { setQuery(value); search(value); }, [search], ); const handleSelect = useCallback( (value: string) => { setOpen(false); if (value.startsWith("project:")) { // value is "project:" — slice off the 8-char prefix to extract the id. push(p.projectDetail(value.slice(8))); } else { push(p.issueDetail(value)); } }, [push, setOpen, p], ); const handlePageSelect = useCallback( (key: NavKey) => { setOpen(false); push(p[key]()); }, [push, setOpen, p], ); const handleMemberSelect = useCallback( (userId: string) => { push(p.memberDetail(userId)); setOpen(false); }, [push, setOpen, p], ); return ( {t(($) => $.title)} {t(($) => $.description)} {/* Search input */}
$.placeholder)} value={query} onValueChange={handleValueChange} className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" />
{/* Results list */} {/* Pages section — only shown when query matches */} {filteredPages.length > 0 && (
{t(($) => $.groups.pages)}
{filteredPages.map((page) => { const PageIcon = routeIconForPath(p[page.key]()); return ( handlePageSelect(page.key)} className="flex cursor-default select-none items-center gap-2.5 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" > ); })}
)} {/* Commands section — New Issue / New Project / Copy link / Theme, only shown when query matches */} {filteredCommands.length > 0 && (
{t(($) => $.groups.commands)}
{filteredCommands.map((cmd) => ( {cmd.trailing} ))}
)} {filteredMembers.length > 0 && (
{t(($) => $.groups.members)}
{filteredMembers.map((member) => ( handleMemberSelect(member.user_id)} className="flex cursor-default select-none items-center gap-2.5 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" >
))}
)} {isLoading && (
)} {!isLoading && query.trim() && !hasResults && filteredPages.length === 0 && filteredCommands.length === 0 && ( {t(($) => $.empty.no_results)} )} {!isLoading && results.projects.length > 0 && ( $.groups.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) => (
{PROJECT_STATUS_CONFIG[project.status as ProjectStatus]?.label ?? project.status}
{project.match_source === "description" && project.matched_snippet && (
)}
))}
)} {!isLoading && results.issues.length > 0 && ( $.groups.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) => (
{issue.identifier}
{issue.matched_description_snippet && (
)} {issue.matched_comment_snippet && (
)}
))}
)} {!isLoading && !query.trim() && recentIssues.length > 0 && (
{t(($) => $.groups.recent)}
{recentIssues.map((item) => ( {item.identifier} {item.title} ))}
)} {!isLoading && !query.trim() && recentIssues.length === 0 && (
{t(($) => $.empty.type_to_search)}
)}
); }