From 2f434202359ab65aa5934a2fbec5165fabca8f22 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 6 Apr 2026 20:27:55 -0700 Subject: [PATCH] Add command palette --- frontend/package-lock.json | 21 +- frontend/package.json | 1 + frontend/src/App.tsx | 16 + frontend/src/components/AppShell.tsx | 20 +- frontend/src/components/CommandPalette.tsx | 439 ++++++++++++++++++ frontend/src/components/ConversationPane.tsx | 6 + frontend/src/components/RepeaterDashboard.tsx | 13 + frontend/src/components/ui/command.tsx | 144 ++++++ frontend/src/test/conversationPane.test.tsx | 2 + 9 files changed, 659 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/CommandPalette.tsx create mode 100644 frontend/src/components/ui/command.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 54fb75e..9da983d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "remoteterm-meshcore-frontend", - "version": "3.6.3", + "version": "3.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "remoteterm-meshcore-frontend", - "version": "3.6.3", + "version": "3.8.0", "dependencies": { "@codemirror/lang-python": "^6.2.1", "@codemirror/theme-one-dark": "^6.1.3", @@ -20,6 +20,7 @@ "@uiw/react-codemirror": "^4.25.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "d3-force": "^3.0.0", "d3-force-3d": "^3.0.6", "leaflet": "^1.9.4", @@ -3687,6 +3688,22 @@ "node": ">=6" } }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/codemirror": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1276c46..6e15d61 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,6 +28,7 @@ "@uiw/react-codemirror": "^4.25.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "d3-force": "^3.0.0", "d3-force-3d": "^3.0.6", "leaflet": "^1.9.4", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 61ed56a..b33739c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -88,6 +88,7 @@ export function App() { useState(null); const [showBulkAddChannelTab, setShowBulkAddChannelTab] = useState(false); const [bulkAddResult, setBulkAddResult] = useState(null); + const [repeaterAutoLoginKey, setRepeaterAutoLoginKey] = useState(null); const [visibilityVersion, setVisibilityVersion] = useState(0); const lastUnreadBackfillAttemptRef = useRef(null); const { @@ -457,6 +458,18 @@ export function App() { [fetchUndecryptedCount, setChannels] ); + const handleRepeaterAutoLogin = useCallback( + (publicKey: string, displayName: string) => { + handleSelectConversationWithTargetReset({ + type: 'contact', + id: publicKey, + name: displayName, + }); + setRepeaterAutoLoginKey(publicKey); + }, + [handleSelectConversationWithTargetReset] + ); + const handleOpenNewMessage = useCallback( (event?: MouseEvent) => { setNewMessagePrefillRequest(null); @@ -587,6 +600,8 @@ export function App() { }, trackedTelemetryRepeaters: appSettings?.tracked_telemetry_repeaters ?? [], onToggleTrackedTelemetry: handleToggleTrackedTelemetry, + repeaterAutoLoginKey, + onClearRepeaterAutoLogin: () => setRepeaterAutoLoginKey(null), }; const searchProps = { contacts, @@ -720,6 +735,7 @@ export function App() { bulkAddChannelResultModalProps={bulkAddChannelResultModalProps} contactInfoPaneProps={contactInfoPaneProps} channelInfoPaneProps={channelInfoPaneProps} + onRepeaterAutoLogin={handleRepeaterAutoLogin} /> ); diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index bc34179..073adbd 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useRef, type ComponentProps } from 'react'; +import { lazy, Suspense, useCallback, useRef, type ComponentProps } from 'react'; import { useSwipeable } from 'react-swipeable'; import { StatusBar } from './StatusBar'; @@ -8,6 +8,7 @@ import { NewMessageModal } from './NewMessageModal'; import { BulkAddChannelResultModal } from './BulkAddChannelResultModal'; import { ContactInfoPane } from './ContactInfoPane'; import { ChannelInfoPane } from './ChannelInfoPane'; +import { CommandPalette } from './CommandPalette'; import { SecurityWarningModal } from './SecurityWarningModal'; import { Toaster } from './ui/sonner'; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet'; @@ -71,6 +72,7 @@ interface AppShellProps { bulkAddChannelResultModalProps: BulkAddChannelResultModalProps; contactInfoPaneProps: ContactInfoPaneProps; channelInfoPaneProps: ChannelInfoPaneProps; + onRepeaterAutoLogin: (publicKey: string, displayName: string) => void; } export function AppShell({ @@ -100,6 +102,7 @@ export function AppShell({ bulkAddChannelResultModalProps, contactInfoPaneProps, channelInfoPaneProps, + onRepeaterAutoLogin, }: AppShellProps) { const swipeHandlers = useSwipeable({ onSwipedRight: ({ initial }) => { @@ -119,6 +122,14 @@ export function AppShell({ preventScrollOnSwipe: false, }); + const handleOpenSettings = useCallback( + (section: SettingsSection) => { + onSettingsSectionChange(section); + if (!showSettings) onToggleSettingsView(); + }, + [onSettingsSectionChange, onToggleSettingsView, showSettings] + ); + const searchMounted = useRef(false); if (conversationPaneProps.activeConversation?.type === 'search') { searchMounted.current = true; @@ -323,6 +334,13 @@ export function AppShell({ onClose={onCloseBulkAddResults} /> + diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx new file mode 100644 index 0000000..da31ffd --- /dev/null +++ b/frontend/src/components/CommandPalette.tsx @@ -0,0 +1,439 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Hash, + Map, + MessageSquare, + Network, + Radio, + Route, + Search, + Sparkles, + 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; +} + +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(items: T[], query: string): T[] { + if (!query) return items.slice(0, MAX_PER_GROUP); + const results: T[] = []; + for (const item of items) { + if (fuzzyMatch(item.searchText, query)) { + 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} ${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} ${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 ( + { + setOpen(nextOpen); + if (!nextOpen) setQuery(''); + }} + > + + Command palette + + Search for conversations, settings, and tools + + + + + {totalResults === 0 && No results found.} + + {fTools.length > 0 && ( + + {fTools.map((tool) => ( + + select(() => + onSelectConversation({ type: tool.type, id: tool.id, name: tool.name }) + ) + } + > + + {tool.name} + + ))} + + )} + + {fSettings.length > 0 && ( + + {fSettings.map((item) => ( + select(() => onOpenSettings(item.section))} + > + + {item.label} + + ))} + + )} + + {fFavContacts.length > 0 && ( + + )} + + {fFavRepeaters.length > 0 && ( + + )} + + {fFavChannels.length > 0 && ( + + {fFavChannels.map(({ channel: ch }) => ( + + select(() => + onSelectConversation({ type: 'channel', id: ch.key, name: ch.name }) + ) + } + > + + {ch.name} + + + ))} + + )} + + {fContacts.length > 0 && ( + + )} + + {fRepeaters.length > 0 && ( + + )} + + {fRooms.length > 0 && ( + + )} + + {fChannels.length > 0 && ( + + {fChannels.map(({ channel: ch }) => ( + + select(() => + onSelectConversation({ type: 'channel', id: ch.key, name: ch.name }) + ) + } + > + + {ch.name} + + ))} + + )} + + + + + ); +} + +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 ( + + {items.map(({ contact: c, displayName }) => ( + + onSelect(() => + onSelectConversation({ type: 'contact', id: c.public_key, name: displayName }) + ) + } + > + + {displayName} + {showStar && } + + ))} + + ); +} + +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 ( + + {items.flatMap(({ contact: c, displayName }) => [ + + onSelect(() => + onSelectConversation({ type: 'contact', id: c.public_key, name: displayName }) + ) + } + > + + {displayName} + {showStar && } + , + + onSelect(() => onRepeaterAutoLogin(c.public_key, displayName)) + } + > + + + {displayName}{' '} + (ACL login + load all) + + , + ])} + + ); +} diff --git a/frontend/src/components/ConversationPane.tsx b/frontend/src/components/ConversationPane.tsx index b1694eb..0ca62d2 100644 --- a/frontend/src/components/ConversationPane.tsx +++ b/frontend/src/components/ConversationPane.tsx @@ -79,6 +79,8 @@ interface ConversationPaneProps { onToggleNotifications: () => void; trackedTelemetryRepeaters: string[]; onToggleTrackedTelemetry: (publicKey: string) => Promise; + repeaterAutoLoginKey: string | null; + onClearRepeaterAutoLogin: () => void; } function LoadingPane({ label }: { label: string }) { @@ -149,6 +151,8 @@ export function ConversationPane({ onToggleNotifications, trackedTelemetryRepeaters, onToggleTrackedTelemetry, + repeaterAutoLoginKey, + onClearRepeaterAutoLogin, }: ConversationPaneProps) { const [roomAuthenticated, setRoomAuthenticated] = useState(false); const activeContactIsRepeater = useMemo(() => { @@ -248,6 +252,8 @@ export function ConversationPane({ onOpenContactInfo={onOpenContactInfo} trackedTelemetryRepeaters={trackedTelemetryRepeaters} onToggleTrackedTelemetry={onToggleTrackedTelemetry} + autoLoginAndLoadAll={repeaterAutoLoginKey === activeConversation.id} + onAutoLoginConsumed={onClearRepeaterAutoLogin} /> ); diff --git a/frontend/src/components/RepeaterDashboard.tsx b/frontend/src/components/RepeaterDashboard.tsx index 12d31e4..d1127ba 100644 --- a/frontend/src/components/RepeaterDashboard.tsx +++ b/frontend/src/components/RepeaterDashboard.tsx @@ -48,6 +48,8 @@ interface RepeaterDashboardProps { onOpenContactInfo?: (publicKey: string) => void; trackedTelemetryRepeaters: string[]; onToggleTrackedTelemetry: (publicKey: string) => Promise; + autoLoginAndLoadAll?: boolean; + onAutoLoginConsumed?: () => void; } export function RepeaterDashboard({ @@ -67,6 +69,8 @@ export function RepeaterDashboard({ onOpenContactInfo, trackedTelemetryRepeaters, onToggleTrackedTelemetry, + autoLoginAndLoadAll, + onAutoLoginConsumed, }: RepeaterDashboardProps) { const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false); const contact = contacts.find((c) => c.public_key === conversation.id) ?? null; @@ -125,6 +129,15 @@ export function RepeaterDashboard({ setTelemetryHistory(liveHistory); }, [paneData.status?.telemetry_history]); + // Command palette "ACL login + load all" auto-action + const autoLoginConsumedRef = useRef(false); + useEffect(() => { + if (!autoLoginAndLoadAll || autoLoginConsumedRef.current) return; + autoLoginConsumedRef.current = true; + onAutoLoginConsumed?.(); + void loginAsGuest().then(() => loadAll()); + }, [autoLoginAndLoadAll, onAutoLoginConsumed, loginAsGuest, loadAll]); + const isFav = contact?.favorite ?? false; const handleRepeaterLogin = async (nextPassword: string) => { diff --git a/frontend/src/components/ui/command.tsx b/frontend/src/components/ui/command.tsx new file mode 100644 index 0000000..cdfc020 --- /dev/null +++ b/frontend/src/components/ui/command.tsx @@ -0,0 +1,144 @@ +'use client'; + +import * as React from 'react'; +import { Command as CommandPrimitive } from 'cmdk'; +import { Search } from 'lucide-react'; + +import { cn } from '@/lib/utils'; +import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'; + +const Command = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Command.displayName = CommandPrimitive.displayName; + +function CommandDialog({ + children, + ...props +}: React.ComponentProps) { + return ( + + + Command palette + + Search for conversations, settings, and tools + + + {children} + + + + ); +} + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)); +CommandInput.displayName = CommandPrimitive.Input.displayName; + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +CommandList.displayName = CommandPrimitive.List.displayName; + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)); +CommandEmpty.displayName = CommandPrimitive.Empty.displayName; + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +CommandGroup.displayName = CommandPrimitive.Group.displayName; + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +CommandSeparator.displayName = CommandPrimitive.Separator.displayName; + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +CommandItem.displayName = CommandPrimitive.Item.displayName; + +function CommandShortcut({ className, ...props }: React.HTMLAttributes) { + return ( + + ); +} +CommandShortcut.displayName = 'CommandShortcut'; + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +}; diff --git a/frontend/src/test/conversationPane.test.tsx b/frontend/src/test/conversationPane.test.tsx index 23627e5..71858b4 100644 --- a/frontend/src/test/conversationPane.test.tsx +++ b/frontend/src/test/conversationPane.test.tsx @@ -158,6 +158,8 @@ function createProps(overrides: Partial {}), + repeaterAutoLoginKey: null, + onClearRepeaterAutoLogin: vi.fn(), ...overrides, }; }