Files
multica/packages/views/search/search-command.tsx
Jiayuan Zhang 8f92b5fdeb feat(search): add fold/unfold all comments commands to the command palette (#5417)
On an issue page, Cmd+K now offers Fold All Comments / Unfold All
Comments. Folding collapses every thread card via the persisted
comment-collapse store; unfolding also expands resolved threads, whose
session-only expand state moves from issue-detail useState into a new
core resolved-expand store so the palette can drive it (MUL-4763).

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-15 11:21:54 +08:00

757 lines
28 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Check,
Clock,
Copy,
FileText,
Link2,
Loader2,
MessageSquare,
Plus,
SearchIcon,
Inbox,
CircleUser,
ListChevronsDownUp,
ListChevronsUpDown,
ListTodo,
FolderKanban,
Bot,
Monitor,
Moon,
Sun,
BookOpenText,
Settings,
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 { 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";
interface NavPage {
key: NavKey;
label: string;
icon: LucideIcon;
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 (
<ActorAvatar
actorType={assigneeType}
actorId={assigneeId}
size="sm"
profileLink={false}
className="shrink-0"
/>
);
}
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), icon: Inbox, keywords: ["inbox", "notifications", "收件箱"] },
{ key: "myIssues", label: t(($) => $.pages.my_issues), icon: CircleUser, keywords: ["my", "issues", "assigned", "我的"] },
{ key: "issues", label: t(($) => $.pages.issues), icon: ListTodo, keywords: ["issues", "tasks", "bugs"] },
{ key: "projects", label: t(($) => $.pages.projects), icon: FolderKanban, keywords: ["projects", "kanban", "项目"] },
{ key: "agents", label: t(($) => $.pages.agents), icon: Bot, keywords: ["agents", "bots", "ai"] },
{ key: "runtimes", label: t(($) => $.pages.runtimes), icon: Monitor, keywords: ["runtimes", "environments"] },
{ key: "skills", label: t(($) => $.pages.skills), icon: BookOpenText, keywords: ["skills", "library"] },
{ key: "settings", label: t(($) => $.pages.settings), icon: 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<SearchResults>({ issues: [], projects: [] });
const [isLoading, setIsLoading] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortRef = useRef<AbortController | null>(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<CommandItem[]>(() => {
const activeThemeCheck = (value: ThemeValue) =>
theme === value ? (
<Check
aria-label={t(($) => $.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:<id>" — 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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent
finalFocus={false}
className="top-[20%] translate-y-0 overflow-hidden rounded-xl! p-0 sm:max-w-xl!"
showCloseButton={false}
>
<DialogHeader className="sr-only">
<DialogTitle>{t(($) => $.title)}</DialogTitle>
<DialogDescription>
{t(($) => $.description)}
</DialogDescription>
</DialogHeader>
<CommandPrimitive
shouldFilter={false}
className="flex size-full flex-col overflow-hidden rounded-xl bg-popover text-popover-foreground"
>
{/* Search input */}
<div className="flex items-center gap-3 border-b px-4 py-3">
<SearchIcon className="size-5 shrink-0 text-muted-foreground" />
<CommandPrimitive.Input
placeholder={t(($) => $.placeholder)}
value={query}
onValueChange={handleValueChange}
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
<ShortcutKeycaps
shortcut={createShortcutChord("Escape")}
className="hidden shrink-0 sm:inline-flex"
/>
</div>
{/* Results list */}
<CommandPrimitive.List className="max-h-[min(400px,50vh)] overflow-y-auto overflow-x-hidden">
{/* Pages section — only shown when query matches */}
{filteredPages.length > 0 && (
<CommandPrimitive.Group className="p-2">
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
{t(($) => $.groups.pages)}
</div>
{filteredPages.map((page) => (
<CommandPrimitive.Item
key={page.key}
value={`page:${page.key}`}
onSelect={() => 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"
>
<page.icon className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate">
<HighlightText text={page.label} query={query} />
</span>
</CommandPrimitive.Item>
))}
</CommandPrimitive.Group>
)}
{/* Commands section — New Issue / New Project / Copy link / Theme, only shown when query matches */}
{filteredCommands.length > 0 && (
<CommandPrimitive.Group className="p-2">
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
{t(($) => $.groups.commands)}
</div>
{filteredCommands.map((cmd) => (
<CommandPrimitive.Item
key={cmd.key}
value={`command:${cmd.key}`}
onSelect={cmd.onSelect}
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"
>
<cmd.icon className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate">
<HighlightText text={cmd.label} query={query} />
</span>
{cmd.trailing}
</CommandPrimitive.Item>
))}
</CommandPrimitive.Group>
)}
{filteredMembers.length > 0 && (
<CommandPrimitive.Group className="p-2">
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
{t(($) => $.groups.members)}
</div>
{filteredMembers.map((member) => (
<CommandPrimitive.Item
key={member.user_id}
value={`member:${member.user_id}`}
onSelect={() => 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"
>
<ActorAvatarBase
name={member.name}
initials={memberInitials(member.name)}
avatarUrl={resolvePublicFileUrl(member.avatar_url)}
size="md"
/>
<div className="min-w-0 flex-1">
<div className="truncate">
<HighlightText text={member.name} query={query} />
</div>
<div className="truncate text-xs text-muted-foreground">
<HighlightText text={member.email} query={query} />
</div>
</div>
</CommandPrimitive.Item>
))}
</CommandPrimitive.Group>
)}
{isLoading && (
<div className="flex items-center justify-center py-10">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)}
{!isLoading &&
query.trim() &&
!hasResults &&
filteredPages.length === 0 &&
filteredCommands.length === 0 && (
<CommandPrimitive.Empty className="py-10 text-center text-sm text-muted-foreground">
{t(($) => $.empty.no_results)}
</CommandPrimitive.Empty>
)}
{!isLoading && results.projects.length > 0 && (
<CommandPrimitive.Group
heading={t(($) => $.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) => (
<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">
<ProjectIcon project={project} size="md" />
<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={t(($) => $.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) => (
<CommandPrimitive.Item
key={issue.id}
value={issue.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">
<StatusIcon
status={issue.status}
className="size-4 shrink-0"
/>
<span className="text-xs text-muted-foreground shrink-0">
{issue.identifier}
</span>
<span className="min-w-0 flex-1 truncate">
<HighlightText text={issue.title} query={query} />
</span>
<IssueAssigneeAvatar
assigneeType={issue.assignee_type}
assigneeId={issue.assignee_id}
/>
</div>
{issue.matched_description_snippet && (
<div className="flex items-start gap-2 pl-[26px]">
<FileText className="size-3 shrink-0 text-muted-foreground mt-0.5" />
<span className="text-xs text-muted-foreground truncate">
<HighlightText
text={issue.matched_description_snippet}
query={query}
/>
</span>
</div>
)}
{issue.matched_comment_snippet && (
<div className="flex items-start gap-2 pl-[26px]">
<MessageSquare className="size-3 shrink-0 text-muted-foreground mt-0.5" />
<span className="text-xs text-muted-foreground truncate">
<HighlightText
text={issue.matched_comment_snippet}
query={query}
/>
</span>
</div>
)}
</CommandPrimitive.Item>
))}
</CommandPrimitive.Group>
)}
{!isLoading && !query.trim() && recentIssues.length > 0 && (
<CommandPrimitive.Group className="p-2">
<div className="flex items-center gap-2 px-3 py-1.5 text-xs font-medium text-muted-foreground">
<Clock className="size-3" />
<span>{t(($) => $.groups.recent)}</span>
</div>
{recentIssues.map((item) => (
<CommandPrimitive.Item
key={item.id}
value={item.id}
onSelect={handleSelect}
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"
>
<StatusIcon
status={item.status}
className="size-4 shrink-0"
/>
<span className="text-xs text-muted-foreground shrink-0">
{item.identifier}
</span>
<span className="min-w-0 flex-1 truncate">{item.title}</span>
<IssueAssigneeAvatar
assigneeType={item.assignee_type}
assigneeId={item.assignee_id}
/>
</CommandPrimitive.Item>
))}
</CommandPrimitive.Group>
)}
{!isLoading && !query.trim() && recentIssues.length === 0 && (
<div className="px-5 py-4 text-center text-xs text-muted-foreground">
{t(($) => $.empty.type_to_search)}
</div>
)}
</CommandPrimitive.List>
</CommandPrimitive>
</DialogContent>
</Dialog>
);
}