mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-23 18:17:44 +02:00
Previously every registered Command (New Issue, New Project, three theme switches, plus contextual Copy actions on issue pages) surfaced on empty query, leaving only 3–5 rows for Recent in a 400px panel. Low-frequency commands (theme, copy, New Project) are now revealed by typing, matching the progressive-disclosure pattern already used for Pages and Switch Workspace. Refs MUL-991.
686 lines
25 KiB
TypeScript
686 lines
25 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import {
|
|
Check,
|
|
Clock,
|
|
Copy,
|
|
Link2,
|
|
Loader2,
|
|
MessageSquare,
|
|
Plus,
|
|
SearchIcon,
|
|
Inbox,
|
|
CircleUser,
|
|
ListTodo,
|
|
FolderKanban,
|
|
Bot,
|
|
Monitor,
|
|
Moon,
|
|
Sun,
|
|
BookOpenText,
|
|
Settings,
|
|
Building2,
|
|
type LucideIcon,
|
|
} from "lucide-react";
|
|
import { Command as CommandPrimitive } from "cmdk";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { toast } from "sonner";
|
|
import type { SearchIssueResult, SearchProjectResult } from "@multica/core/types";
|
|
import { api } from "@multica/core/api";
|
|
import { useRecentIssuesStore } from "@multica/core/issues/stores";
|
|
import { issueListOptions } from "@multica/core/issues/queries";
|
|
import { useWorkspaceId } from "@multica/core";
|
|
import { paths, useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths";
|
|
import type { WorkspacePaths } from "@multica/core/paths";
|
|
import { useModalStore } from "@multica/core/modals";
|
|
import { workspaceListOptions } from "@multica/core/workspace/queries";
|
|
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,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
} from "@multica/ui/components/ui/dialog";
|
|
import { useTheme } from "@multica/ui/components/common/theme-provider";
|
|
import { useNavigation } from "../navigation";
|
|
import { useSearchStore } from "./search-store";
|
|
|
|
function HighlightText({ text, query }: { text: string; query: string }) {
|
|
const parts = useMemo(() => {
|
|
if (!query.trim()) return [{ text, highlight: false }];
|
|
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
const regex = new RegExp(`(${escaped})`, "gi");
|
|
const result: { text: string; highlight: boolean }[] = [];
|
|
let lastIndex = 0;
|
|
let match: RegExpExecArray | null;
|
|
while ((match = regex.exec(text)) !== null) {
|
|
if (match.index > lastIndex) {
|
|
result.push({ text: text.slice(lastIndex, match.index), highlight: false });
|
|
}
|
|
result.push({ text: match[0], highlight: true });
|
|
lastIndex = regex.lastIndex;
|
|
}
|
|
if (lastIndex < text.length) {
|
|
result.push({ text: text.slice(lastIndex), highlight: false });
|
|
}
|
|
return result.length > 0 ? result : [{ text, highlight: false }];
|
|
}, [text, query]);
|
|
|
|
return (
|
|
<>
|
|
{parts.map((part, i) =>
|
|
part.highlight ? (
|
|
<mark key={i} className="bg-yellow-200 dark:bg-yellow-900/60 text-inherit rounded-sm">
|
|
{part.text}
|
|
</mark>
|
|
) : (
|
|
part.text
|
|
),
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
// 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[];
|
|
}
|
|
|
|
const navPages: NavPage[] = [
|
|
{ key: "inbox", label: "Inbox", icon: Inbox, keywords: ["inbox", "notifications"] },
|
|
{ key: "myIssues", label: "My Issues", icon: CircleUser, keywords: ["my", "issues", "assigned"] },
|
|
{ key: "issues", label: "Issues", icon: ListTodo, keywords: ["issues", "tasks", "bugs"] },
|
|
{ key: "projects", label: "Projects", icon: FolderKanban, keywords: ["projects", "kanban"] },
|
|
{ key: "agents", label: "Agents", icon: Bot, keywords: ["agents", "bots", "ai"] },
|
|
{ key: "runtimes", label: "Runtimes", icon: Monitor, keywords: ["runtimes", "environments"] },
|
|
{ key: "skills", label: "Skills", icon: BookOpenText, keywords: ["skills", "library"] },
|
|
{ key: "settings", label: "Settings", icon: Settings, keywords: ["settings", "config", "preferences"] },
|
|
];
|
|
|
|
type ThemeValue = "light" | "dark" | "system";
|
|
|
|
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 { push, pathname, getShareableUrl } = useNavigation();
|
|
const open = useSearchStore((s) => s.open);
|
|
const setOpen = useSearchStore((s) => s.setOpen);
|
|
const recentItems = useRecentIssuesStore((s) => s.items);
|
|
const wsId = useWorkspaceId();
|
|
const p: WorkspacePaths = useWorkspacePaths();
|
|
const { data: allIssues = [] } = useQuery(issueListOptions(wsId));
|
|
const { theme, setTheme } = useTheme();
|
|
const currentWorkspace = useCurrentWorkspace();
|
|
const { data: workspaces = [] } = useQuery(workspaceListOptions());
|
|
|
|
const recentIssues = useMemo(() => {
|
|
const issueMap = new Map(allIssues.map((i) => [i.id, i]));
|
|
return recentItems.flatMap((item) => {
|
|
const issue = issueMap.get(item.id);
|
|
return issue ? [issue] : [];
|
|
});
|
|
}, [recentItems, allIssues]);
|
|
|
|
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 currentIssue = useMemo(() => {
|
|
const match = pathname.match(/\/issues\/([^/]+)$/);
|
|
const raw = match?.[1];
|
|
if (!raw) return null;
|
|
const id = decodeURIComponent(raw);
|
|
return allIssues.find((i) => i.id === id) ?? null;
|
|
}, [pathname, allIssues]);
|
|
|
|
const commands = useMemo<CommandItem[]>(() => {
|
|
const activeThemeCheck = (value: ThemeValue) =>
|
|
theme === value ? (
|
|
<Check
|
|
aria-label="Current theme"
|
|
className="ml-auto size-4 shrink-0 text-muted-foreground"
|
|
/>
|
|
) : undefined;
|
|
|
|
const items: CommandItem[] = [
|
|
{
|
|
key: "new-issue",
|
|
label: "New Issue",
|
|
icon: Plus,
|
|
keywords: ["new", "issue", "create", "add"],
|
|
onSelect: () => {
|
|
useModalStore.getState().open("create-issue");
|
|
setOpen(false);
|
|
},
|
|
},
|
|
{
|
|
key: "new-project",
|
|
label: "New Project",
|
|
icon: Plus,
|
|
keywords: ["new", "project", "create", "add"],
|
|
onSelect: () => {
|
|
useModalStore.getState().open("create-project");
|
|
setOpen(false);
|
|
},
|
|
},
|
|
];
|
|
|
|
if (currentIssue) {
|
|
const identifier = currentIssue.identifier;
|
|
items.push(
|
|
{
|
|
key: "copy-issue-link",
|
|
label: "Copy Issue Link",
|
|
icon: Link2,
|
|
keywords: ["copy", "link", "share", "url", identifier.toLowerCase()],
|
|
onSelect: () => {
|
|
const url = getShareableUrl ? getShareableUrl(pathname) : window.location.href;
|
|
void navigator.clipboard.writeText(url);
|
|
toast.success("Link copied");
|
|
setOpen(false);
|
|
},
|
|
},
|
|
{
|
|
key: "copy-issue-identifier",
|
|
label: `Copy Identifier (${identifier})`,
|
|
icon: Copy,
|
|
keywords: ["copy", "id", "identifier", identifier.toLowerCase()],
|
|
onSelect: () => {
|
|
void navigator.clipboard.writeText(identifier);
|
|
toast.success(`Copied ${identifier}`);
|
|
setOpen(false);
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
items.push(
|
|
{
|
|
key: "theme-light",
|
|
label: "Switch to Light Theme",
|
|
icon: Sun,
|
|
keywords: ["light", "theme", "appearance", "mode", "bright"],
|
|
trailing: activeThemeCheck("light"),
|
|
onSelect: () => {
|
|
setTheme("light");
|
|
setOpen(false);
|
|
},
|
|
},
|
|
{
|
|
key: "theme-dark",
|
|
label: "Switch to Dark Theme",
|
|
icon: Moon,
|
|
keywords: ["dark", "theme", "appearance", "mode", "night"],
|
|
trailing: activeThemeCheck("dark"),
|
|
onSelect: () => {
|
|
setTheme("dark");
|
|
setOpen(false);
|
|
},
|
|
},
|
|
{
|
|
key: "theme-system",
|
|
label: "Use System Theme",
|
|
icon: Monitor,
|
|
keywords: ["system", "theme", "appearance", "mode", "auto"],
|
|
trailing: activeThemeCheck("system"),
|
|
onSelect: () => {
|
|
setTheme("system");
|
|
setOpen(false);
|
|
},
|
|
},
|
|
);
|
|
|
|
return items;
|
|
}, [currentIssue, getShareableUrl, pathname, setOpen, setTheme, theme]);
|
|
|
|
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]);
|
|
|
|
// Only show workspaces different from the current one, and only after the
|
|
// user types >=2 chars — one char would match everything (e.g. "w").
|
|
const filteredWorkspaces = useMemo(() => {
|
|
const q = query.trim().toLowerCase();
|
|
if (!q) return [];
|
|
const others = workspaces.filter((w) => w.id !== currentWorkspace?.id);
|
|
const wantsAll =
|
|
q.length >= 2 && ("workspace".startsWith(q) || "switch".startsWith(q));
|
|
return others.filter(
|
|
(w) =>
|
|
wantsAll ||
|
|
w.name.toLowerCase().includes(q) ||
|
|
w.slug.toLowerCase().includes(q),
|
|
);
|
|
}, [workspaces, currentWorkspace?.id, query]);
|
|
|
|
const hasResults = results.issues.length > 0 || results.projects.length > 0;
|
|
|
|
// Global Cmd+K / Ctrl+K shortcut
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
|
e.preventDefault();
|
|
useSearchStore.getState().toggle();
|
|
}
|
|
};
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
}, []);
|
|
|
|
// 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 handleSwitchWorkspace = useCallback(
|
|
(slug: string) => {
|
|
push(paths.workspace(slug).issues());
|
|
setOpen(false);
|
|
},
|
|
[push, setOpen],
|
|
);
|
|
|
|
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>Search</DialogTitle>
|
|
<DialogDescription>
|
|
Search pages, issues, and projects
|
|
</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="Type a command or search..."
|
|
value={query}
|
|
onValueChange={handleValueChange}
|
|
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
|
/>
|
|
<kbd className="hidden shrink-0 rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground sm:inline">
|
|
ESC
|
|
</kbd>
|
|
</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">
|
|
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">
|
|
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>
|
|
)}
|
|
|
|
{/* Workspaces section — switch to a different workspace, only shown when query matches */}
|
|
{filteredWorkspaces.length > 0 && (
|
|
<CommandPrimitive.Group className="p-2">
|
|
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
|
Switch Workspace
|
|
</div>
|
|
{filteredWorkspaces.map((ws) => (
|
|
<CommandPrimitive.Item
|
|
key={ws.id}
|
|
value={`workspace:${ws.id}`}
|
|
onSelect={() => handleSwitchWorkspace(ws.slug)}
|
|
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"
|
|
>
|
|
<Building2 className="size-4 shrink-0 text-muted-foreground" />
|
|
<span className="truncate">
|
|
<HighlightText text={ws.name} query={query} />
|
|
</span>
|
|
<span className="ml-auto text-xs text-muted-foreground truncate">
|
|
{ws.slug}
|
|
</span>
|
|
</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 &&
|
|
filteredWorkspaces.length === 0 && (
|
|
<CommandPrimitive.Empty className="py-10 text-center text-sm text-muted-foreground">
|
|
No results found.
|
|
</CommandPrimitive.Empty>
|
|
)}
|
|
|
|
{!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}
|
|
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="truncate">
|
|
<HighlightText text={issue.title} query={query} />
|
|
</span>
|
|
<span
|
|
className={`ml-auto text-xs shrink-0 ${STATUS_CONFIG[issue.status].iconColor}`}
|
|
>
|
|
{STATUS_CONFIG[issue.status].label}
|
|
</span>
|
|
</div>
|
|
{issue.match_source === "comment" &&
|
|
issue.matched_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_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>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="truncate">{item.title}</span>
|
|
<span
|
|
className={`ml-auto text-xs shrink-0 ${STATUS_CONFIG[item.status]?.iconColor ?? ""}`}
|
|
>
|
|
{STATUS_CONFIG[item.status]?.label ?? ""}
|
|
</span>
|
|
</CommandPrimitive.Item>
|
|
))}
|
|
</CommandPrimitive.Group>
|
|
)}
|
|
|
|
{!isLoading && !query.trim() && recentIssues.length === 0 && (
|
|
<div className="px-5 py-4 text-center text-xs text-muted-foreground">
|
|
Type to search issues and projects
|
|
</div>
|
|
)}
|
|
</CommandPrimitive.List>
|
|
</CommandPrimitive>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|