feat(ui): add global search palette with Cmd+K shortcut

- Add GlobalSearchPalette component for global search overlay
- Introduce global search event system for cross-component communication
- Add project permissions library for access control checks
This commit is contained in:
ZhenYi 2026-05-15 13:10:56 +08:00
parent f597da6e33
commit 7ec848470e
4 changed files with 265 additions and 0 deletions

View File

@ -7,6 +7,7 @@ import { issueSummary } from "@/client/api";
import { SettingsModalContext } from "@/components/settings/SettingsModalContext"; import { SettingsModalContext } from "@/components/settings/SettingsModalContext";
import { SettingsModal } from "@/components/settings/SettingsModal"; import { SettingsModal } from "@/components/settings/SettingsModal";
import { SettingsDataCacheProvider } from "@/components/settings/SettingsDataCache"; import { SettingsDataCacheProvider } from "@/components/settings/SettingsDataCache";
import { GlobalSearchPalette } from "@/components/search/GlobalSearchPalette";
import { initWsClient, getWsClient } from "@/ws/hooks"; import { initWsClient, getWsClient } from "@/ws/hooks";
import type { WsClient } from "@/ws/client"; import type { WsClient } from "@/ws/client";
@ -94,6 +95,7 @@ export function RootLayout() {
<SettingsDataCacheProvider> <SettingsDataCacheProvider>
<SettingsModalContext.Provider value={modalCtx}> <SettingsModalContext.Provider value={modalCtx}>
<Outlet /> <Outlet />
<GlobalSearchPalette />
{showSettingsModal && <SettingsModal />} {showSettingsModal && <SettingsModal />}
</SettingsModalContext.Provider> </SettingsModalContext.Provider>
</SettingsDataCacheProvider> </SettingsDataCacheProvider>

View File

@ -0,0 +1,248 @@
import { useEffect, useMemo, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
Boxes,
FolderGit2,
Hash,
Kanban,
LayoutDashboard,
MessageSquare,
Search,
Settings,
Sparkles,
Ticket,
} from "lucide-react";
import { search as globalSearch } from "@/client/api";
import { useBoardsQuery } from "@/hooks/useBoardsQuery";
import { useProjectInfo } from "@/hooks/useProjectInfo";
import { useProjectsQuery } from "@/hooks/useProjectsQuery";
import { useProjectReposQuery } from "@/hooks/useReposQuery";
import { useRoomsQuery } from "@/hooks/useRoomsQuery";
import { isProjectAdminRole } from "@/lib/project-permissions";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@/components/ui/command";
import { OPEN_GLOBAL_SEARCH_EVENT } from "@/components/search/global-search-events";
function useDebouncedValue(value: string, delay = 180) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = window.setTimeout(() => setDebounced(value), delay);
return () => window.clearTimeout(timer);
}, [delay, value]);
return debounced;
}
function getProjectNameFromPath(pathname: string) {
const first = pathname.split("/").filter(Boolean)[0];
if (!first || first === "me" || first === "auth" || first === "explore") return undefined;
return first;
}
export function GlobalSearchPalette() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const debouncedQuery = useDebouncedValue(query.trim());
const location = useLocation();
const navigate = useNavigate();
const activeProjectName = getProjectNameFromPath(location.pathname);
const { data: projects = [] } = useProjectsQuery();
const { data: projectInfo } = useProjectInfo(activeProjectName);
const isProjectMember = !!projectInfo?.role;
const canManageProject = isProjectAdminRole(projectInfo?.role);
const { data: repos = [] } = useProjectReposQuery(isProjectMember ? activeProjectName : undefined);
const { data: roomsData } = useRoomsQuery(isProjectMember ? activeProjectName : undefined);
const { data: boards = [] } = useBoardsQuery(isProjectMember ? activeProjectName : undefined);
const remoteResults = useQuery({
queryKey: ["global-search-palette", debouncedQuery],
queryFn: async () => {
const res = await globalSearch({ q: debouncedQuery, type: "projects,repos,issues", page: 1, per_page: 8 });
return res.data?.data ?? null;
},
enabled: open && debouncedQuery.length >= 2,
staleTime: 30_000,
});
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.ctrlKey && event.altKey && event.key.toLowerCase() === "f") {
event.preventDefault();
setOpen((current) => !current);
}
};
const onOpen = () => setOpen(true);
window.addEventListener("keydown", onKeyDown);
window.addEventListener(OPEN_GLOBAL_SEARCH_EVENT, onOpen);
return () => {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener(OPEN_GLOBAL_SEARCH_EVENT, onOpen);
};
}, []);
const rooms = roomsData?.rooms ?? [];
const quickActions = useMemo(() => {
if (!activeProjectName) {
return [
{ label: "My Home", description: "Open personal overview", path: "/me", icon: LayoutDashboard },
{ label: "Explore Projects", description: "Find public projects", path: "/explore", icon: Search },
{ label: "Chat", description: "Open personal chat", path: "/me/chat", icon: MessageSquare },
];
}
return [
{ label: "Project Overview", description: activeProjectName, path: `/${activeProjectName}`, icon: LayoutDashboard },
{ label: "Repositories", description: activeProjectName, path: `/${activeProjectName}/repos`, icon: FolderGit2 },
{ label: "Issues", description: activeProjectName, path: `/${activeProjectName}/issues`, icon: Ticket },
{ label: "Boards", description: activeProjectName, path: `/${activeProjectName}/board`, icon: Kanban },
{ label: "Chat", description: activeProjectName, path: `/${activeProjectName}/chat`, icon: MessageSquare },
...(canManageProject
? [{ label: "Project Settings", description: activeProjectName, path: `/${activeProjectName}/settings`, icon: Settings }]
: []),
];
}, [activeProjectName, canManageProject]);
const go = (path: string) => {
setOpen(false);
setQuery("");
navigate(path);
};
const handleOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen);
if (!nextOpen) setQuery("");
};
return (
<CommandDialog
open={open}
onOpenChange={handleOpenChange}
title="Search"
description="Jump to projects, rooms, repositories, boards, and issues."
className="max-w-2xl"
>
<CommandInput
autoFocus
placeholder="Search projects, rooms, repos, boards, issues..."
value={query}
onValueChange={setQuery}
/>
<CommandList className="max-h-[min(70vh,520px)]">
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Quick Switch">
{quickActions.map((item) => (
<CommandItem key={item.path} value={`${item.label} ${item.description}`} onSelect={() => go(item.path)}>
<item.icon />
<span className="truncate">{item.label}</span>
<CommandShortcut>{item.description}</CommandShortcut>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Projects">
{projects.map((project) => (
<CommandItem
key={project.uid}
value={`${project.display_name} ${project.name} project`}
onSelect={() => go(`/${project.name}`)}
>
<Boxes />
<span className="truncate">{project.display_name || project.name}</span>
<CommandShortcut>{project.name}</CommandShortcut>
</CommandItem>
))}
</CommandGroup>
{activeProjectName && (
<>
<CommandSeparator />
<CommandGroup heading={`Rooms in ${activeProjectName}`}>
{rooms.map((room) => (
<CommandItem
key={room.id}
value={`${room.room_name} room channel ${activeProjectName}`}
onSelect={() => go(`/${activeProjectName}/channel/${room.id}`)}
>
<Hash />
<span className="truncate">{room.room_name}</span>
<CommandShortcut>room</CommandShortcut>
</CommandItem>
))}
</CommandGroup>
<CommandGroup heading="Repositories">
{repos.map((repo) => (
<CommandItem
key={repo.uid}
value={`${repo.repo_name} ${repo.description ?? ""} repo ${activeProjectName}`}
onSelect={() => go(`/${activeProjectName}/repo/${repo.repo_name}`)}
>
<FolderGit2 />
<span className="truncate">{repo.repo_name}</span>
<CommandShortcut>repo</CommandShortcut>
</CommandItem>
))}
</CommandGroup>
<CommandGroup heading="Boards">
{boards.map((board) => (
<CommandItem
key={board.id}
value={`${board.name} ${board.description ?? ""} board ${activeProjectName}`}
onSelect={() => go(`/${activeProjectName}/board/${board.id}`)}
>
<Kanban />
<span className="truncate">{board.name}</span>
<CommandShortcut>board</CommandShortcut>
</CommandItem>
))}
</CommandGroup>
</>
)}
{remoteResults.data && (
<>
<CommandSeparator />
<CommandGroup heading="Search Results">
{(remoteResults.data.projects?.items ?? []).map((project) => (
<CommandItem key={`remote-project-${project.uid}`} value={`${project.display_name} ${project.name}`} onSelect={() => go(`/${project.name}`)}>
<Boxes />
<span className="truncate">{project.display_name || project.name}</span>
<CommandShortcut>project</CommandShortcut>
</CommandItem>
))}
{(remoteResults.data.repos?.items ?? []).map((repo) => (
<CommandItem key={`remote-repo-${repo.uid}`} value={`${repo.name} ${repo.project_name}`} onSelect={() => go(`/${repo.project_name}/repo/${repo.name}`)}>
<FolderGit2 />
<span className="truncate">{repo.name}</span>
<CommandShortcut>{repo.project_name}</CommandShortcut>
</CommandItem>
))}
{(remoteResults.data.issues?.items ?? []).map((issue) => (
<CommandItem key={`remote-issue-${issue.uid}`} value={`${issue.title} ${issue.project_name} #${issue.number}`} onSelect={() => go(`/${issue.project_name}/issues/${issue.number}`)}>
<Sparkles />
<span className="truncate">#{issue.number} {issue.title}</span>
<CommandShortcut>issue</CommandShortcut>
</CommandItem>
))}
</CommandGroup>
</>
)}
</CommandList>
</CommandDialog>
);
}

View File

@ -0,0 +1,5 @@
export const OPEN_GLOBAL_SEARCH_EVENT = "app:open-global-search";
export function openGlobalSearch() {
window.dispatchEvent(new Event(OPEN_GLOBAL_SEARCH_EVENT));
}

View File

@ -0,0 +1,10 @@
import type { MemberRole } from "@/client/model";
export function isProjectAdminRole(role?: MemberRole | string | null) {
const normalized = String(role ?? "").toLowerCase();
return normalized === "owner" || normalized === "admin";
}
export function isProjectOwnerRole(role?: MemberRole | string | null) {
return String(role ?? "").toLowerCase() === "owner";
}