gitdataai/src/components/shared/CommandPalette.tsx
ZhenYi 7620f2f281 feat(command): use real API data for navigation, fix notification button
CommandPalette: replace workspaceProjects with getCurrentUserProjects
(no workspace dependency so it works outside WorkspaceProvider).
Repos fetched per-project to preserve correct /repository/ns/repo
routes. Keyboard shortcut correctly matches Ctrl+Alt+F / Cmd+Ctrl+F.

sidebar-user: fix notification button layout — bell icon and label
now on the same row instead of separate stacked elements.
2026-04-25 09:53:12 +08:00

277 lines
9.2 KiB
TypeScript

'use client';
/**
* Global command palette (Cmd+K / Ctrl+K).
* Dynamic: fetches real projects, repos, rooms from API.
* Actions: navigate to project/repo/room, create project, create repo.
*/
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import {
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
} from '@/components/ui/command';
import {
FolderKanban,
GitBranch,
Hash,
Plus,
Bell,
Search,
} from 'lucide-react';
import { getRegisteredCommands } from '@/hooks/useCommandRegistry';
import type { CommandItem as RegistryCommandItem } from '@/hooks/useCommandRegistry';
import { formatShortcut } from '@/hooks/useKeyboardShortcut';
import { getCurrentUserProjects, projectRepos, roomList } from '@/client';
import type { UserProjectInfo, ProjectRepositoryItem, RoomResponse } from '@/client';
// ── Icons ────────────────────────────────────────────────────────────────────
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
FolderKanban,
GitBranch,
Hash,
Plus,
Bell,
Search,
};
// ── Command item type ────────────────────────────────────────────────────────
interface PaletteItem {
id: string;
label: string;
icon: string;
shortcut?: { key: string; meta?: boolean; shift?: boolean };
action: () => void;
group: string;
keywords: string[];
}
// ── Static quick actions ─────────────────────────────────────────────────────
function buildStaticActions(navigate: ReturnType<typeof useNavigate>): PaletteItem[] {
return [
{
id: 'goto-notifications',
label: 'Go to Notifications',
icon: 'Bell',
shortcut: { key: 'n', meta: true },
action: () => navigate('/notify'),
group: 'Navigation',
keywords: ['goto', 'notifications', 'inbox'],
},
{
id: 'create-project',
label: 'Create Project',
icon: 'Plus',
shortcut: { key: 'c', meta: true, shift: true },
action: () => navigate('/init/project'),
group: 'Create',
keywords: ['create', 'new', 'project'],
},
];
}
// ── Main palette ─────────────────────────────────────────────────────────────
export function CommandPalette() {
const [open, setOpen] = useState(false);
const navigate = useNavigate();
// Fetch projects for the current user (no workspace dependency)
const { data: projectsData } = useQuery({
queryKey: ['commandPaletteProjects'],
queryFn: async () => {
const resp = await getCurrentUserProjects();
return resp.data?.data ?? { projects: [] as UserProjectInfo[], total_count: 0 };
},
});
const projects = projectsData?.projects ?? [];
// Derived project names for repo/room queries
const projectNames = useMemo(
() => projects.map(p => p.name),
[projects],
);
// Fetch repos per project (up to 5 projects to keep it fast)
const { data: reposData } = useQuery({
queryKey: ['commandPaletteRepos', projectNames],
queryFn: async () => {
const results: Record<string, ProjectRepositoryItem[]> = {};
for (const name of projectNames.slice(0, 5)) {
try {
const resp = await projectRepos({ path: { project_name: name } });
const data = resp.data?.data;
if (data?.items) results[name] = data.items;
} catch { /* skip */ }
}
return results;
},
enabled: projectNames.length > 0,
});
// Fetch rooms for each project (up to 5 to keep it fast)
const { data: roomsData } = useQuery({
queryKey: ['commandPaletteRooms', projectNames],
queryFn: async () => {
const results: Record<string, RoomResponse[]> = {};
for (const name of projectNames.slice(0, 5)) {
try {
const resp = await roomList({ path: { project_name: name } });
const data = resp.data?.data;
if (data) results[name] = (data as any)?.rooms ?? (Array.isArray(data) ? data : []);
} catch { /* skip */ }
}
return results;
},
enabled: projectNames.length > 0,
});
// Global shortcut: Ctrl+Alt+F (Windows/Linux) or Cmd+Ctrl+F (Mac)
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const isMac = navigator.platform?.toUpperCase().includes('MAC') || navigator.userAgent?.includes('Mac');
const match = isMac
? e.metaKey && e.ctrlKey && e.key === 'f'
: e.ctrlKey && e.altKey && e.key === 'f';
if (match) {
e.preventDefault();
setOpen((v) => !v);
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);
// Close on navigation
useEffect(() => {
const handler = () => setOpen(false);
window.addEventListener('popstate', handler);
return () => window.removeEventListener('popstate', handler);
}, []);
const handleSelect = useCallback((action: () => void) => {
setOpen(false);
action();
}, []);
// Build search text including keywords for cmdk filtering
const searchValue = useCallback((item: PaletteItem | RegistryCommandItem) => {
return [item.label, ...(item.keywords ?? [])].join(' ');
}, []);
// ── Build dynamic command list ──────────────────────────────────────────
const allRooms = roomsData ?? {};
const allRepos = reposData ?? {};
const projectItems: PaletteItem[] = projects.map(p => ({
id: `project-${p.name}`,
label: p.display_name || p.name,
icon: 'FolderKanban',
action: () => navigate(`/project/${p.name}`),
group: 'Projects',
keywords: ['project', p.name, p.display_name, ...(p.description ? [p.description] : [])],
}));
const repoItems: PaletteItem[] = [];
for (const [projName, repos] of Object.entries(allRepos)) {
for (const r of repos) {
repoItems.push({
id: `repo-${projName}-${r.repo_name}`,
label: `${r.repo_name} (${projName})`,
icon: 'GitBranch',
action: () => navigate(`/repository/${projName}/${r.repo_name}`),
group: 'Repositories',
keywords: ['repo', 'repository', r.repo_name, projName, ...(r.description ? [r.description] : [])],
});
}
}
const roomItems: PaletteItem[] = [];
for (const [projName, rooms] of Object.entries(allRooms)) {
for (const r of rooms) {
roomItems.push({
id: `room-${r.id}`,
label: `${r.room_name} (${projName})`,
icon: 'Hash',
action: () => navigate(`/project/${projName}/room/${r.id}`),
group: 'Rooms',
keywords: ['room', 'chat', 'channel', r.room_name, projName],
});
}
}
// Per-project "create repo" actions
const createRepoItems: PaletteItem[] = projects.slice(0, 10).map(p => ({
id: `create-repo-${p.name}`,
label: `Create Repo in ${p.display_name || p.name}`,
icon: 'Plus',
action: () => navigate(`/project/${p.name}/repositories`),
group: 'Create',
keywords: ['create', 'new', 'repo', 'repository', p.name, p.display_name],
}));
const staticActions = buildStaticActions(navigate);
const registered = getRegisteredCommands();
const allCommands = [
...staticActions,
...projectItems,
...repoItems,
...roomItems,
...createRepoItems,
...registered,
] as (PaletteItem | RegistryCommandItem)[];
// Group commands by their `group` field
const grouped = useMemo(() => {
const groups = new Map<string, (PaletteItem | RegistryCommandItem)[]>();
for (const cmd of allCommands) {
const g = groups.get(cmd.group) ?? [];
g.push(cmd);
groups.set(cmd.group, g);
}
return groups;
}, [allCommands]);
return (
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput placeholder="Search projects, repos, rooms, commands…" autoFocus />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
{Array.from(grouped.entries()).map(([group, cmds]) => (
<CommandGroup key={group} heading={group}>
{cmds.map((cmd) => {
const Icon = iconMap[(cmd as PaletteItem).icon ?? ''] ?? Search;
const shortcut = cmd.shortcut;
return (
<CommandItem
key={cmd.id}
value={searchValue(cmd)}
onSelect={() => handleSelect(cmd.action)}
>
<Icon className="mr-2 h-4 w-4 shrink-0" />
<span className="flex-1">{cmd.label}</span>
{shortcut && (
<CommandShortcut>{formatShortcut(shortcut)}</CommandShortcut>
)}
</CommandItem>
);
})}
</CommandGroup>
))}
</CommandList>
</CommandDialog>
);
}