diff --git a/apps/desktop/src/lib/trpc/routers/settings/index.ts b/apps/desktop/src/lib/trpc/routers/settings/index.ts index cc4d47661bd..9fae5170f8f 100644 --- a/apps/desktop/src/lib/trpc/routers/settings/index.ts +++ b/apps/desktop/src/lib/trpc/routers/settings/index.ts @@ -158,6 +158,47 @@ export const createSettingsRouter = () => { return { success: true }; }), + reorderTerminalPresets: publicProcedure + .input( + z.object({ + presetId: z.string(), + targetIndex: z.number().int().min(0), + }), + ) + .mutation(({ input }) => { + const row = getSettings(); + const presets = row.terminalPresets ?? []; + + const currentIndex = presets.findIndex((p) => p.id === input.presetId); + if (currentIndex === -1) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Preset not found", + }); + } + + if (input.targetIndex < 0 || input.targetIndex >= presets.length) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Invalid target index for reordering presets", + }); + } + + const [removed] = presets.splice(currentIndex, 1); + presets.splice(input.targetIndex, 0, removed); + + localDb + .insert(settings) + .values({ id: 1, terminalPresets: presets }) + .onConflictDoUpdate({ + target: settings.id, + set: { terminalPresets: presets }, + }) + .run(); + + return { success: true }; + }), + getDefaultPreset: publicProcedure.query(() => { const row = getSettings(); const presets = row.terminalPresets ?? []; diff --git a/apps/desktop/src/renderer/react-query/presets/index.ts b/apps/desktop/src/renderer/react-query/presets/index.ts index 97ace8013e1..a2c2b888985 100644 --- a/apps/desktop/src/renderer/react-query/presets/index.ts +++ b/apps/desktop/src/renderer/react-query/presets/index.ts @@ -65,6 +65,22 @@ function useSetDefaultPreset( }); } +function useReorderTerminalPresets( + options?: Parameters< + typeof electronTrpc.settings.reorderTerminalPresets.useMutation + >[0], +) { + const utils = electronTrpc.useUtils(); + + return electronTrpc.settings.reorderTerminalPresets.useMutation({ + ...options, + onSuccess: async (...args) => { + await utils.settings.getTerminalPresets.invalidate(); + await options?.onSuccess?.(...args); + }, + }); +} + /** * Combined hook for accessing terminal presets with all CRUD operations * Provides easy access to presets data and mutations from anywhere in the app @@ -80,6 +96,7 @@ export function usePresets() { const updatePreset = useUpdateTerminalPreset(); const deletePreset = useDeleteTerminalPreset(); const setDefaultPreset = useSetDefaultPreset(); + const reorderPresets = useReorderTerminalPresets(); return { presets, @@ -89,5 +106,6 @@ export function usePresets() { updatePreset, deletePreset, setDefaultPreset, + reorderPresets, }; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/hooks/usePresetHotkeys.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/hooks/usePresetHotkeys.ts new file mode 100644 index 00000000000..c0e96233167 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/hooks/usePresetHotkeys.ts @@ -0,0 +1,46 @@ +import { useAppHotkey } from "renderer/stores/hotkeys"; +import type { HotkeyId } from "shared/hotkeys"; + +const PRESET_HOTKEY_IDS: HotkeyId[] = [ + "OPEN_PRESET_1", + "OPEN_PRESET_2", + "OPEN_PRESET_3", + "OPEN_PRESET_4", + "OPEN_PRESET_5", + "OPEN_PRESET_6", + "OPEN_PRESET_7", + "OPEN_PRESET_8", + "OPEN_PRESET_9", +]; + +export function usePresetHotkeys( + openTabWithPreset: (presetIndex: number) => void, +) { + useAppHotkey(PRESET_HOTKEY_IDS[0], () => openTabWithPreset(0), undefined, [ + openTabWithPreset, + ]); + useAppHotkey(PRESET_HOTKEY_IDS[1], () => openTabWithPreset(1), undefined, [ + openTabWithPreset, + ]); + useAppHotkey(PRESET_HOTKEY_IDS[2], () => openTabWithPreset(2), undefined, [ + openTabWithPreset, + ]); + useAppHotkey(PRESET_HOTKEY_IDS[3], () => openTabWithPreset(3), undefined, [ + openTabWithPreset, + ]); + useAppHotkey(PRESET_HOTKEY_IDS[4], () => openTabWithPreset(4), undefined, [ + openTabWithPreset, + ]); + useAppHotkey(PRESET_HOTKEY_IDS[5], () => openTabWithPreset(5), undefined, [ + openTabWithPreset, + ]); + useAppHotkey(PRESET_HOTKEY_IDS[6], () => openTabWithPreset(6), undefined, [ + openTabWithPreset, + ]); + useAppHotkey(PRESET_HOTKEY_IDS[7], () => openTabWithPreset(7), undefined, [ + openTabWithPreset, + ]); + useAppHotkey(PRESET_HOTKEY_IDS[8], () => openTabWithPreset(8), undefined, [ + openTabWithPreset, + ]); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx index 48a62006de6..410abb02656 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx @@ -2,7 +2,9 @@ import { createFileRoute, notFound, useNavigate } from "@tanstack/react-router"; import { useCallback, useMemo } from "react"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { electronTrpcClient as trpcClient } from "renderer/lib/trpc-client"; +import { usePresets } from "renderer/react-query/presets"; import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; +import { usePresetHotkeys } from "renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/hooks/usePresetHotkeys"; import { NotFound } from "renderer/routes/not-found"; import { WorkspaceInitializingView } from "renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView"; import { WorkspaceLayout } from "renderer/screens/main/components/WorkspaceView/WorkspaceLayout"; @@ -111,16 +113,33 @@ function WorkspacePage() { const focusedPaneId = activeTabId ? focusedPaneIds[activeTabId] : null; - // Tab management shortcuts - useAppHotkey( - "NEW_GROUP", - () => { - addTab(workspaceId); + const { presets } = usePresets(); + const renameTab = useTabsStore((s) => s.renameTab); + + const openTabWithPreset = useCallback( + (presetIndex: number) => { + const preset = presets[presetIndex]; + if (preset) { + const result = addTab(workspaceId, { + initialCommands: preset.commands, + initialCwd: preset.cwd || undefined, + }); + if (preset.name) { + renameTab(result.tabId, preset.name); + } + } else { + addTab(workspaceId); + } }, - undefined, - [workspaceId, addTab], + [presets, workspaceId, addTab, renameTab], ); + useAppHotkey("NEW_GROUP", () => addTab(workspaceId), undefined, [ + workspaceId, + addTab, + ]); + usePresetHotkeys(openTabWithPreset); + useAppHotkey( "CLOSE_TERMINAL", () => { @@ -132,9 +151,8 @@ function WorkspacePage() { [focusedPaneId, removePane], ); - // Switch between tabs useAppHotkey( - "PREV_TERMINAL", + "PREV_TAB", () => { if (!activeTabId) return; const index = tabs.findIndex((t) => t.id === activeTabId); @@ -147,7 +165,7 @@ function WorkspacePage() { ); useAppHotkey( - "NEXT_TERMINAL", + "NEXT_TAB", () => { if (!activeTabId) return; const index = tabs.findIndex((t) => t.id === activeTabId); @@ -159,7 +177,6 @@ function WorkspacePage() { [workspaceId, activeTabId, tabs, setActiveTab], ); - // Switch between panes within a tab useAppHotkey( "PREV_PANE", () => { diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/keyboard/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/keyboard/page.tsx index 2d0a72f6361..aa00a30efd9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/keyboard/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/keyboard/page.tsx @@ -121,19 +121,18 @@ function KeyboardShortcutsPage() { const showHotkeysDisplay = useHotkeyDisplay("SHOW_HOTKEYS"); - const allHotkeys = useMemo( - () => - CATEGORY_ORDER.flatMap((category) => hotkeysByCategory[category] ?? []), - [hotkeysByCategory], - ); - - const filteredHotkeys = useMemo(() => { - if (!searchQuery) return allHotkeys; + const filteredHotkeysByCategory = useMemo(() => { + if (!searchQuery) return hotkeysByCategory; const lower = searchQuery.toLowerCase(); - return allHotkeys.filter((hotkey) => - hotkey.label.toLowerCase().includes(lower), - ); - }, [allHotkeys, searchQuery]); + return Object.fromEntries( + CATEGORY_ORDER.map((category) => [ + category, + (hotkeysByCategory[category] ?? []).filter((hotkey) => + hotkey.label.toLowerCase().includes(lower), + ), + ]), + ) as typeof hotkeysByCategory; + }, [hotkeysByCategory, searchQuery]); useEffect(() => { if (!recordingId) return; @@ -296,36 +295,51 @@ function KeyboardShortcutsPage() { /> - {/* Table */} -
-
- - Command - - - Shortcut - -
+ {/* Tables by Category */} +
+ {CATEGORY_ORDER.map((category) => { + const hotkeys = filteredHotkeysByCategory[category] ?? []; + if (hotkeys.length === 0) return null; -
- {filteredHotkeys.length > 0 ? ( - filteredHotkeys.map((hotkey) => ( - handleStartRecording(hotkey.id)} - onReset={() => resetHotkey(hotkey.id)} - /> - )) - ) : ( -
- No shortcuts found matching "{searchQuery}" + return ( +
+

+ {category} +

+
+
+ + Command + + + Shortcut + +
+
+ {hotkeys.map((hotkey) => ( + handleStartRecording(hotkey.id)} + onReset={() => resetHotkey(hotkey.id)} + /> + ))} +
+
- )} -
+ ); + })} + + {CATEGORY_ORDER.every( + (cat) => (filteredHotkeysByCategory[cat] ?? []).length === 0, + ) && ( +
+ No shortcuts found matching "{searchQuery}" +
+ )}
{/* Conflict dialog */} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/TerminalSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/TerminalSettings.tsx index 2279cdb9478..b7ef342bb51 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/TerminalSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/TerminalSettings.tsx @@ -23,7 +23,7 @@ import { import { toast } from "@superset/ui/sonner"; import { Switch } from "@superset/ui/switch"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { HiOutlineCheck, HiOutlinePlus, @@ -145,11 +145,17 @@ export function TerminalSettings({ visibleItems }: TerminalSettingsProps) { updatePreset, deletePreset, setDefaultPreset, + reorderPresets, } = usePresets(); const [localPresets, setLocalPresets] = useState(serverPresets); const presetsContainerRef = useRef(null); const prevPresetsCountRef = useRef(serverPresets.length); + const serverPresetsRef = useRef(serverPresets); + + useEffect(() => { + serverPresetsRef.current = serverPresets; + }, [serverPresets]); useEffect(() => { setLocalPresets(serverPresets); @@ -173,97 +179,159 @@ export function TerminalSettings({ visibleItems }: TerminalSettingsProps) { const isTemplateAdded = (template: PresetTemplate) => existingPresetNames.has(template.preset.name); - const handleCellChange = ( - rowIndex: number, - column: PresetColumnKey, - value: string, - ) => { - setLocalPresets((prev) => - prev.map((p, i) => (i === rowIndex ? { ...p, [column]: value } : p)), - ); - }; + const handleCellChange = useCallback( + (rowIndex: number, column: PresetColumnKey, value: string) => { + setLocalPresets((prev) => + prev.map((p, i) => (i === rowIndex ? { ...p, [column]: value } : p)), + ); + }, + [], + ); - const handleCellBlur = (rowIndex: number, column: PresetColumnKey) => { - const preset = localPresets[rowIndex]; - const serverPreset = serverPresets[rowIndex]; - if (!preset || !serverPreset) return; - if (preset[column] === serverPreset[column]) return; + const handleCellBlur = useCallback( + (rowIndex: number, column: PresetColumnKey) => { + setLocalPresets((currentLocal) => { + const preset = currentLocal[rowIndex]; + if (!preset) return currentLocal; + const serverPreset = serverPresetsRef.current.find( + (p) => p.id === preset.id, + ); + if (!serverPreset) return currentLocal; + if (preset[column] === serverPreset[column]) return currentLocal; - updatePreset.mutate({ - id: preset.id, - patch: { [column]: preset[column] }, - }); - }; + updatePreset.mutate({ + id: preset.id, + patch: { [column]: preset[column] }, + }); + return currentLocal; + }); + }, + [updatePreset], + ); - const handleCommandsChange = (rowIndex: number, commands: string[]) => { - const preset = localPresets[rowIndex]; - const isDelete = preset && commands.length < preset.commands.length; + const handleCommandsChange = useCallback( + (rowIndex: number, commands: string[]) => { + setLocalPresets((prev) => { + const preset = prev[rowIndex]; + const isDelete = preset && commands.length < preset.commands.length; + const newPresets = prev.map((p, i) => + i === rowIndex ? { ...p, commands } : p, + ); - setLocalPresets((prev) => - prev.map((p, i) => (i === rowIndex ? { ...p, commands } : p)), - ); + // Save immediately on delete since onBlur won't have the updated state yet + if (isDelete && preset) { + updatePreset.mutate({ + id: preset.id, + patch: { commands }, + }); + } + return newPresets; + }); + }, + [updatePreset], + ); + + const handleCommandsBlur = useCallback( + (rowIndex: number) => { + setLocalPresets((currentLocal) => { + const preset = currentLocal[rowIndex]; + if (!preset) return currentLocal; + const serverPreset = serverPresetsRef.current.find( + (p) => p.id === preset.id, + ); + if (!serverPreset) return currentLocal; + if ( + JSON.stringify(preset.commands) === + JSON.stringify(serverPreset.commands) + ) + return currentLocal; - // Save immediately on delete since onBlur won't have the updated state yet - if (isDelete) { - updatePreset.mutate({ - id: preset.id, - patch: { commands }, + updatePreset.mutate({ + id: preset.id, + patch: { commands: preset.commands }, + }); + return currentLocal; }); - } - }; + }, + [updatePreset], + ); - const handleCommandsBlur = (rowIndex: number) => { - const preset = localPresets[rowIndex]; - const serverPreset = serverPresets[rowIndex]; - if (!preset || !serverPreset) return; - if ( - JSON.stringify(preset.commands) === JSON.stringify(serverPreset.commands) - ) - return; - - updatePreset.mutate({ - id: preset.id, - patch: { commands: preset.commands }, - }); - }; + const handleExecutionModeChange = useCallback( + (rowIndex: number, mode: ExecutionMode) => { + setLocalPresets((currentLocal) => { + const preset = currentLocal[rowIndex]; + if (!preset) return currentLocal; - const handleExecutionModeChange = (rowIndex: number, mode: ExecutionMode) => { - const preset = localPresets[rowIndex]; - if (!preset) return; + const newPresets = currentLocal.map((p, i) => + i === rowIndex ? { ...p, executionMode: mode } : p, + ); - setLocalPresets((prev) => - prev.map((p, i) => (i === rowIndex ? { ...p, executionMode: mode } : p)), - ); + updatePreset.mutate({ + id: preset.id, + patch: { executionMode: mode }, + }); - updatePreset.mutate({ - id: preset.id, - patch: { executionMode: mode }, - }); - }; + return newPresets; + }); + }, + [updatePreset], + ); - const handleAddRow = () => { + const handleAddRow = useCallback(() => { createPreset.mutate({ name: "", cwd: "", commands: [""], }); - }; + }, [createPreset]); - const handleAddTemplate = (template: PresetTemplate) => { - if (isTemplateAdded(template)) return; - createPreset.mutate(template.preset); - }; + const handleAddTemplate = useCallback( + (template: PresetTemplate) => { + if (existingPresetNames.has(template.preset.name)) return; + createPreset.mutate(template.preset); + }, + [createPreset, existingPresetNames], + ); - const handleDeleteRow = (rowIndex: number) => { - const preset = localPresets[rowIndex]; - if (!preset) return; + const handleDeleteRow = useCallback( + (rowIndex: number) => { + setLocalPresets((currentLocal) => { + const preset = currentLocal[rowIndex]; + if (preset) { + deletePreset.mutate({ id: preset.id }); + } + return currentLocal; + }); + }, + [deletePreset], + ); - deletePreset.mutate({ id: preset.id }); - }; + const handleSetDefault = useCallback( + (presetId: string | null) => { + setDefaultPreset.mutate({ id: presetId }); + }, + [setDefaultPreset], + ); + + const handleLocalReorder = useCallback( + (fromIndex: number, toIndex: number) => { + setLocalPresets((prev) => { + const newPresets = [...prev]; + const [removed] = newPresets.splice(fromIndex, 1); + newPresets.splice(toIndex, 0, removed); + return newPresets; + }); + }, + [], + ); + + const handlePersistReorder = useCallback( + (presetId: string, targetIndex: number) => { + reorderPresets.mutate({ presetId, targetIndex }); + }, + [reorderPresets], + ); - const handleSetDefault = (presetId: string | null) => { - setDefaultPreset.mutate({ id: presetId }); - }; const { data: terminalPersistence, isLoading } = electronTrpc.settings.getTerminalPersistence.useQuery(); @@ -522,6 +590,7 @@ export function TerminalSettings({ visibleItems }: TerminalSettingsProps) { {showPresets && (
+
{PRESET_COLUMNS.map((column) => (
)) ) : ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetRow/PresetRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetRow/PresetRow.tsx index d2a3b20c923..785d5e298db 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetRow/PresetRow.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetRow/PresetRow.tsx @@ -9,8 +9,10 @@ import { SelectValue, } from "@superset/ui/select"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { useRef } from "react"; +import { useDrag, useDrop } from "react-dnd"; import { HiOutlineStar, HiStar } from "react-icons/hi2"; -import { LuTrash } from "react-icons/lu"; +import { LuGripVertical, LuTrash } from "react-icons/lu"; import { PRESET_COLUMNS, type PresetColumnConfig, @@ -19,6 +21,8 @@ import { } from "renderer/routes/_authenticated/settings/presets/types"; import { CommandsEditor } from "./components/CommandsEditor"; +const PRESET_TYPE = "TERMINAL_PRESET"; + interface PresetCellProps { column: PresetColumnConfig; preset: TerminalPreset; @@ -74,6 +78,8 @@ interface PresetRowProps { onExecutionModeChange: (rowIndex: number, mode: ExecutionMode) => void; onDelete: (rowIndex: number) => void; onSetDefault: (presetId: string | null) => void; + onLocalReorder: (fromIndex: number, toIndex: number) => void; + onPersistReorder: (presetId: string, targetIndex: number) => void; } export function PresetRow({ @@ -87,18 +93,58 @@ export function PresetRow({ onExecutionModeChange, onDelete, onSetDefault, + onLocalReorder, + onPersistReorder, }: PresetRowProps) { + const rowRef = useRef(null); + const dragHandleRef = useRef(null); + + const [{ isDragging }, drag, preview] = useDrag( + () => ({ + type: PRESET_TYPE, + item: { id: preset.id, index: rowIndex, originalIndex: rowIndex }, + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + }), + [preset.id, rowIndex], + ); + + const [, drop] = useDrop({ + accept: PRESET_TYPE, + hover: (item: { id: string; index: number; originalIndex: number }) => { + if (item.index !== rowIndex) { + onLocalReorder(item.index, rowIndex); + item.index = rowIndex; + } + }, + drop: (item: { id: string; index: number; originalIndex: number }) => { + if (item.originalIndex !== item.index) { + onPersistReorder(item.id, item.index); + } + }, + }); + + preview(drop(rowRef)); + drag(dragHandleRef); + const handleToggleDefault = () => { - // If already default, clear it; otherwise set this preset as default onSetDefault(preset.isDefault ? null : preset.id); }; return (
+
+ +
{PRESET_COLUMNS.map((column) => (