diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette/index.tsx similarity index 69% rename from src/components/CommandPalette.tsx rename to src/components/CommandPalette/index.tsx index 9561866c..36842097 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette/index.tsx @@ -1,4 +1,3 @@ -import { Avatar } from "./UI/Avatar.tsx"; import { CommandDialog, CommandEmpty, @@ -18,7 +17,6 @@ import { FactoryIcon, LayersIcon, LinkIcon, - type LucideIcon, MapIcon, MessageSquareIcon, PlusIcon, @@ -29,10 +27,13 @@ import { SmartphoneIcon, TrashIcon, UsersIcon, - XCircleIcon, + Pin, + type LucideIcon, } from "lucide-react"; import { useEffect } from "react"; -import { RebootDialog } from "@components/Dialog/RebootDialog.tsx"; +import { Avatar } from "@components/UI/Avatar.tsx"; +import { cn } from "@core/utils/cn.ts"; +import { usePinnedItems } from "@core/hooks/usePinnedItems.tsx"; export interface Group { label: string; @@ -46,7 +47,6 @@ export interface Command { subItems?: SubItem[]; tags?: string[]; } - export interface SubItem { label: string; icon: React.ReactNode; @@ -58,11 +58,10 @@ export const CommandPalette = () => { commandPaletteOpen, setCommandPaletteOpen, setSelectedDevice, - removeDevice, - selectedDevice, } = useAppStore(); const { getDevices } = useDeviceStore(); const { setDialogOpen, setActivePage, connection } = useDevice(); + const { pinnedItems, togglePinnedItem } = usePinnedItems({ storageName: 'pinnedCommandMenuGroups' }); const groups: Group[] = [ { @@ -114,22 +113,22 @@ export const CommandPalette = () => { { label: "Switch Node", icon: ArrowLeftRightIcon, - subItems: getDevices().map((device) => { - return { - label: - device.nodes.get(device.hardware.myNodeNum)?.user?.longName ?? - device.hardware.myNodeNum.toString(), - icon: ( - - ), - action() { - setSelectedDevice(device.id); - }, - }; - }), + subItems: getDevices().map((device) => ({ + label: + device.nodes.get(device.hardware.myNodeNum)?.user?.longName ?? + device.hardware.myNodeNum.toString(), + icon: ( + + ), + action() { + setSelectedDevice(device.id); + }, + })), }, { label: "Connect New Node", @@ -164,22 +163,6 @@ export const CommandPalette = () => { }, ], }, - { - label: "Disconnect", - icon: XCircleIcon, - action() { - void connection?.disconnect(); - setSelectedDevice(0); - removeDevice(selectedDevice ?? 0); - }, - }, - { - label: "Reboot", - icon: PowerIcon, - action() { - connection?.reboot(0); - }, - }, { label: "Schedule Shutdown", icon: PowerIcon, @@ -194,6 +177,13 @@ export const CommandPalette = () => { setDialogOpen("reboot", true); }, }, + { + label: "Reboot To OTA Mode", + icon: RefreshCwIcon, + action() { + setDialogOpen("rebootOTA", true); + }, + }, { label: "Reset Nodes", icon: TrashIcon, @@ -239,6 +229,12 @@ export const CommandPalette = () => { }, ]; + const sortedGroups = [...groups].sort((a, b) => { + const aPinned = pinnedItems.includes(a.label) ? 1 : 0; + const bPinned = pinnedItems.includes(b.label) ? 1 : 0; + return bPinned - aPinned; + }); + useEffect(() => { const handleKeydown = (e: KeyboardEvent) => { if (e.key === "k" && (e.metaKey || e.ctrlKey)) { @@ -252,15 +248,45 @@ export const CommandPalette = () => { }, [setCommandPaletteOpen]); return ( - + No results found. - {groups.map((group) => ( - + {sortedGroups.map((group) => ( + + {group.label} + + + } + > {group.commands.map((command) => (
{ @@ -78,6 +79,12 @@ export const DialogManager = () => { setDialogOpen("refreshKeys", open); }} /> + { + setDialogOpen("rebootOTA", open); + }} + /> { diff --git a/src/components/Dialog/RebootOTADialog.test.tsx b/src/components/Dialog/RebootOTADialog.test.tsx new file mode 100644 index 00000000..5dc3726f --- /dev/null +++ b/src/components/Dialog/RebootOTADialog.test.tsx @@ -0,0 +1,114 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { RebootOTADialog } from './RebootOTADialog.tsx'; +import { ReactNode } from "react"; + +const rebootOtaMock = vi.fn(); +let mockConnection: { rebootOta: (delay: number) => void } | undefined = { + rebootOta: rebootOtaMock, +}; + +vi.mock('@core/stores/deviceStore.ts', () => ({ + useDevice: () => ({ + connection: mockConnection, + }), +})); + +vi.mock('@components/UI/Button.tsx', async () => { + const actual = await vi.importActual('@components/UI/Button.tsx'); + return { + ...actual, + Button: (props: any) => +
+ + + + + ); +}; + diff --git a/src/components/UI/Command.tsx b/src/components/UI/Command.tsx index 3173179c..d230d326 100644 --- a/src/components/UI/Command.tsx +++ b/src/components/UI/Command.tsx @@ -116,7 +116,7 @@ const CommandItem = React.forwardRef< (storageName, []); + + const togglePinnedItem = useCallback((label: string) => { + setPinnedItems((prev) => + prev.includes(label) + ? prev.filter((g) => g !== label) + : [...prev, label] + ); + }, []); + + return { + pinnedItems, + togglePinnedItem, + }; +} diff --git a/src/core/stores/deviceStore.ts b/src/core/stores/deviceStore.ts index 62b6e4c8..58736738 100644 --- a/src/core/stores/deviceStore.ts +++ b/src/core/stores/deviceStore.ts @@ -23,6 +23,7 @@ export type DialogVariant = | "QR" | "shutdown" | "reboot" + | "rebootOTA" | "deviceName" | "nodeRemoval" | "pkiBackup" @@ -64,6 +65,7 @@ export interface Device { QR: boolean; shutdown: boolean; reboot: boolean; + rebootOTA: boolean; deviceName: boolean; nodeRemoval: boolean; pkiBackup: boolean; @@ -149,6 +151,7 @@ export const useDeviceStore = createStore((set, get) => ({ nodeDetails: false, unsafeRoles: false, refreshKeys: false, + rebootOTA: false, }, pendingSettingsChanges: false, messageDraft: "", diff --git a/vitest.config.ts b/vitest.config.ts index 98878fa3..aaed988c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,9 +11,9 @@ export default defineConfig({ resolve: { alias: { '@app': path.resolve(process.cwd(), './src'), + '@core': path.resolve(process.cwd(), './src/core'), '@pages': path.resolve(process.cwd(), './src/pages'), '@components': path.resolve(process.cwd(), './src/components'), - '@core': path.resolve(process.cwd(), './src/core'), '@layouts': path.resolve(process.cwd(), './src/layouts'), }, },