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.
277 lines
9.2 KiB
TypeScript
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>
|
|
);
|
|
} |