Files
Remote-Terminal-for-MeshCore/frontend/src/components/CommandPalette.tsx
T

442 lines
13 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Hash,
Map,
MessageSquare,
Network,
Radio,
Route,
Search,
Star,
User,
Waypoints,
} from 'lucide-react';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from './ui/command';
import { Dialog, DialogContent, DialogDescription, DialogTitle } from './ui/dialog';
import { getContactDisplayName } from '../utils/pubkey';
import {
SETTINGS_SECTION_LABELS,
SETTINGS_SECTION_ORDER,
SETTINGS_SECTION_ICONS,
type SettingsSection,
} from './settings/settingsConstants';
import type { Channel, Contact, Conversation } from '../types';
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
const MAX_PER_GROUP = 8;
interface CommandPaletteProps {
contacts: Contact[];
channels: Channel[];
onSelectConversation: (conv: Conversation) => void;
onOpenSettings: (section: SettingsSection) => void;
onRepeaterAutoLogin: (publicKey: string, displayName: string) => void;
}
interface Searchable {
searchText: string;
keyText?: string;
}
interface SearchableContact extends Searchable {
contact: Contact;
displayName: string;
}
interface SearchableChannel extends Searchable {
channel: Channel;
}
interface ToolItem extends Searchable {
id: string;
name: string;
icon: React.ComponentType<{ className?: string }>;
type: 'raw' | 'map' | 'visualizer' | 'search' | 'trace';
}
interface SettingItem extends Searchable {
section: SettingsSection;
label: string;
icon: React.ComponentType<{ className?: string }>;
}
const TOOL_ITEMS: ToolItem[] = [
{ id: 'raw', name: 'Raw Packet Feed', icon: Radio, type: 'raw', searchText: 'raw packet feed' },
{ id: 'map', name: 'Map View', icon: Map, type: 'map', searchText: 'map view' },
{
id: 'visualizer',
name: 'Network Visualizer',
icon: Network,
type: 'visualizer',
searchText: 'network visualizer',
},
{
id: 'search',
name: 'Message Search',
icon: Search,
type: 'search',
searchText: 'message search',
},
{ id: 'trace', name: 'Route Trace', icon: Route, type: 'trace', searchText: 'route trace' },
];
const SETTING_ITEMS: SettingItem[] = SETTINGS_SECTION_ORDER.map((section) => ({
section,
label: SETTINGS_SECTION_LABELS[section],
icon: SETTINGS_SECTION_ICONS[section],
searchText: `settings ${SETTINGS_SECTION_LABELS[section]}`.toLowerCase(),
}));
function fuzzyMatch(text: string, query: string): boolean {
let qi = 0;
for (let ti = 0; ti < text.length && qi < query.length; ti++) {
if (text[ti] === query[qi]) qi++;
}
return qi === query.length;
}
function filterList<T extends Searchable>(items: T[], query: string): T[] {
if (!query) return items.slice(0, MAX_PER_GROUP);
const results: T[] = [];
for (const item of items) {
const nameMatch = fuzzyMatch(item.searchText, query);
const keyMatch = item.keyText ? item.keyText.startsWith(query) : false;
if (nameMatch || keyMatch) {
results.push(item);
if (results.length >= MAX_PER_GROUP) break;
}
}
return results;
}
export function CommandPalette({
contacts,
channels,
onSelectConversation,
onOpenSettings,
onRepeaterAutoLogin,
}: CommandPaletteProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((prev) => !prev);
}
}
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, []);
const select = useCallback((action: () => void) => {
setOpen(false);
action();
}, []);
const {
favContacts,
favRepeaters,
regularContacts,
repeaters,
rooms,
favChannels,
regularChannels,
} = useMemo(() => {
const fc: SearchableContact[] = [];
const fr: SearchableContact[] = [];
const rc: SearchableContact[] = [];
const rp: SearchableContact[] = [];
const rm: SearchableContact[] = [];
for (const c of contacts) {
const displayName = getContactDisplayName(c.name, c.public_key, c.last_advert);
const entry: SearchableContact = {
contact: c,
displayName,
searchText: displayName.toLowerCase(),
keyText: c.public_key.toLowerCase(),
};
if (c.type === CONTACT_TYPE_REPEATER) {
(c.favorite ? fr : rp).push(entry);
} else if (c.type === CONTACT_TYPE_ROOM) {
rm.push(entry);
} else {
(c.favorite ? fc : rc).push(entry);
}
}
const fch: SearchableChannel[] = [];
const rch: SearchableChannel[] = [];
for (const ch of channels) {
const entry: SearchableChannel = {
channel: ch,
searchText: ch.name.toLowerCase(),
keyText: ch.key.toLowerCase(),
};
(ch.favorite ? fch : rch).push(entry);
}
return {
favContacts: fc,
favRepeaters: fr,
regularContacts: rc,
repeaters: rp,
rooms: rm,
favChannels: fch,
regularChannels: rch,
};
}, [contacts, channels]);
const lq = query.toLowerCase();
const fTools = filterList(TOOL_ITEMS, lq);
const fSettings = filterList(SETTING_ITEMS, lq);
const fFavContacts = filterList(favContacts, lq);
const fFavRepeaters = filterList(favRepeaters, lq);
const fFavChannels = filterList(favChannels, lq);
const fContacts = filterList(regularContacts, lq);
const fRepeaters = filterList(repeaters, lq);
const fRooms = filterList(rooms, lq);
const fChannels = filterList(regularChannels, lq);
const totalResults =
fTools.length +
fSettings.length +
fFavContacts.length +
fFavRepeaters.length +
fFavChannels.length +
fContacts.length +
fRepeaters.length +
fRooms.length +
fChannels.length;
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (!nextOpen) setQuery('');
}}
>
<DialogContent className="overflow-hidden p-0 shadow-lg" hideCloseButton>
<DialogTitle className="sr-only">Command palette</DialogTitle>
<DialogDescription className="sr-only">
Search for conversations, settings, and tools
</DialogDescription>
<Command shouldFilter={false}>
<CommandInput placeholder="Jump to..." value={query} onValueChange={setQuery} />
<CommandList>
{totalResults === 0 && <CommandEmpty>No results found.</CommandEmpty>}
{fTools.length > 0 && (
<CommandGroup heading="Tools">
{fTools.map((tool) => (
<CommandItem
key={tool.id}
onSelect={() =>
select(() =>
onSelectConversation({ type: tool.type, id: tool.id, name: tool.name })
)
}
>
<tool.icon className="text-muted-foreground" />
<span>{tool.name}</span>
</CommandItem>
))}
</CommandGroup>
)}
{fSettings.length > 0 && (
<CommandGroup heading="Settings">
{fSettings.map((item) => (
<CommandItem
key={item.section}
onSelect={() => select(() => onOpenSettings(item.section))}
>
<item.icon className="text-muted-foreground" />
<span>{item.label}</span>
</CommandItem>
))}
</CommandGroup>
)}
{fFavContacts.length > 0 && (
<ContactGroup
heading="Favorite Contacts"
items={fFavContacts}
icon={User}
onSelect={select}
onSelectConversation={onSelectConversation}
showStar
/>
)}
{fFavRepeaters.length > 0 && (
<RepeaterGroup
heading="Favorite Repeaters"
items={fFavRepeaters}
onSelect={select}
onSelectConversation={onSelectConversation}
onRepeaterAutoLogin={onRepeaterAutoLogin}
showStar
/>
)}
{fFavChannels.length > 0 && (
<CommandGroup heading="Favorite Channels">
{fFavChannels.map(({ channel: ch }) => (
<CommandItem
key={ch.key}
onSelect={() =>
select(() =>
onSelectConversation({ type: 'channel', id: ch.key, name: ch.name })
)
}
>
<Hash className="text-muted-foreground" />
<span>{ch.name}</span>
<Star className="ml-auto h-3 w-3 text-favorite" />
</CommandItem>
))}
</CommandGroup>
)}
{fContacts.length > 0 && (
<ContactGroup
heading="Contacts"
items={fContacts}
icon={User}
onSelect={select}
onSelectConversation={onSelectConversation}
/>
)}
{fRepeaters.length > 0 && (
<RepeaterGroup
heading="Repeaters"
items={fRepeaters}
onSelect={select}
onSelectConversation={onSelectConversation}
onRepeaterAutoLogin={onRepeaterAutoLogin}
/>
)}
{fRooms.length > 0 && (
<ContactGroup
heading="Rooms"
items={fRooms}
icon={MessageSquare}
onSelect={select}
onSelectConversation={onSelectConversation}
/>
)}
{fChannels.length > 0 && (
<CommandGroup heading="Channels">
{fChannels.map(({ channel: ch }) => (
<CommandItem
key={ch.key}
onSelect={() =>
select(() =>
onSelectConversation({ type: 'channel', id: ch.key, name: ch.name })
)
}
>
<Hash className="text-muted-foreground" />
<span>{ch.name}</span>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</DialogContent>
</Dialog>
);
}
function ContactGroup({
heading,
items,
icon: Icon,
showStar,
onSelect,
onSelectConversation,
}: {
heading: string;
items: SearchableContact[];
icon: React.ComponentType<{ className?: string }>;
showStar?: boolean;
onSelect: (action: () => void) => void;
onSelectConversation: (conv: Conversation) => void;
}) {
return (
<CommandGroup heading={heading}>
{items.map(({ contact: c, displayName }) => (
<CommandItem
key={c.public_key}
onSelect={() =>
onSelect(() =>
onSelectConversation({ type: 'contact', id: c.public_key, name: displayName })
)
}
>
<Icon className="text-muted-foreground" />
<span>{displayName}</span>
{showStar && <Star className="ml-auto h-3 w-3 text-favorite" />}
</CommandItem>
))}
</CommandGroup>
);
}
function RepeaterGroup({
heading,
items,
showStar,
onSelect,
onSelectConversation,
onRepeaterAutoLogin,
}: {
heading: string;
items: SearchableContact[];
showStar?: boolean;
onSelect: (action: () => void) => void;
onSelectConversation: (conv: Conversation) => void;
onRepeaterAutoLogin: (publicKey: string, displayName: string) => void;
}) {
return (
<CommandGroup heading={heading}>
{items.flatMap(({ contact: c, displayName }) => [
<CommandItem
key={c.public_key}
onSelect={() =>
onSelect(() =>
onSelectConversation({ type: 'contact', id: c.public_key, name: displayName })
)
}
>
<Waypoints className="text-muted-foreground" />
<span>{displayName}</span>
{showStar && <Star className="ml-auto h-3 w-3 text-favorite" />}
</CommandItem>,
<CommandItem
key={`${c.public_key}-acl`}
onSelect={() => onSelect(() => onRepeaterAutoLogin(c.public_key, displayName))}
>
<Waypoints className="text-muted-foreground" />
<span>
{displayName} <span className="text-muted-foreground">(ACL login + load all)</span>
</span>
</CommandItem>,
])}
</CommandGroup>
);
}