From 7ec848470eebb17db37dcd84b16250abac7ef0d7 Mon Sep 17 00:00:00 2001 From: ZhenYi <434836402@qq.com> Date: Fri, 15 May 2026 13:10:56 +0800 Subject: [PATCH] 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 --- src/app/layout.tsx | 2 + src/components/search/GlobalSearchPalette.tsx | 248 ++++++++++++++++++ src/components/search/global-search-events.ts | 5 + src/lib/project-permissions.ts | 10 + 4 files changed, 265 insertions(+) create mode 100644 src/components/search/GlobalSearchPalette.tsx create mode 100644 src/components/search/global-search-events.ts create mode 100644 src/lib/project-permissions.ts diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 51c761a..d3ae3a4 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -7,6 +7,7 @@ import { issueSummary } from "@/client/api"; import { SettingsModalContext } from "@/components/settings/SettingsModalContext"; import { SettingsModal } from "@/components/settings/SettingsModal"; import { SettingsDataCacheProvider } from "@/components/settings/SettingsDataCache"; +import { GlobalSearchPalette } from "@/components/search/GlobalSearchPalette"; import { initWsClient, getWsClient } from "@/ws/hooks"; import type { WsClient } from "@/ws/client"; @@ -94,6 +95,7 @@ export function RootLayout() { + {showSettingsModal && } diff --git a/src/components/search/GlobalSearchPalette.tsx b/src/components/search/GlobalSearchPalette.tsx new file mode 100644 index 0000000..f36d4f5 --- /dev/null +++ b/src/components/search/GlobalSearchPalette.tsx @@ -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 ( + + + + No results found. + + + {quickActions.map((item) => ( + go(item.path)}> + + {item.label} + {item.description} + + ))} + + + + + + {projects.map((project) => ( + go(`/${project.name}`)} + > + + {project.display_name || project.name} + {project.name} + + ))} + + + {activeProjectName && ( + <> + + + {rooms.map((room) => ( + go(`/${activeProjectName}/channel/${room.id}`)} + > + + {room.room_name} + room + + ))} + + + + {repos.map((repo) => ( + go(`/${activeProjectName}/repo/${repo.repo_name}`)} + > + + {repo.repo_name} + repo + + ))} + + + + {boards.map((board) => ( + go(`/${activeProjectName}/board/${board.id}`)} + > + + {board.name} + board + + ))} + + + )} + + {remoteResults.data && ( + <> + + + {(remoteResults.data.projects?.items ?? []).map((project) => ( + go(`/${project.name}`)}> + + {project.display_name || project.name} + project + + ))} + {(remoteResults.data.repos?.items ?? []).map((repo) => ( + go(`/${repo.project_name}/repo/${repo.name}`)}> + + {repo.name} + {repo.project_name} + + ))} + {(remoteResults.data.issues?.items ?? []).map((issue) => ( + go(`/${issue.project_name}/issues/${issue.number}`)}> + + #{issue.number} {issue.title} + issue + + ))} + + + )} + + + ); +} diff --git a/src/components/search/global-search-events.ts b/src/components/search/global-search-events.ts new file mode 100644 index 0000000..139c759 --- /dev/null +++ b/src/components/search/global-search-events.ts @@ -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)); +} diff --git a/src/lib/project-permissions.ts b/src/lib/project-permissions.ts new file mode 100644 index 0000000..61e778f --- /dev/null +++ b/src/lib/project-permissions.ts @@ -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"; +}