From 193c5ae34f112de75281add5c32a8bc7d085162f Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 7 Dec 2025 16:10:48 -0800 Subject: [PATCH 1/2] feat(desktop): add terminal presets system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add terminal presets to config.json that appear as buttons in the sidebar, allowing users to quickly launch preconfigured terminals with custom cwd and startup commands. Features: - Preset buttons in sidebar below window list - "+ New Preset" button to create presets via modal - "Save as Preset" in terminal context menu (prefills cwd) - Right-click to delete presets - Presets stored in .superset/config.json 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../src/lib/trpc/routers/config/config.ts | 73 +++++++++ .../TabsContent/TabContentContextMenu.tsx | 13 +- .../TabsContent/WindowView/WindowPane.tsx | 18 +++ .../TabsView/PresetModal/PresetModal.tsx | 140 ++++++++++++++++++ .../Sidebar/TabsView/PresetModal/index.ts | 1 + .../TerminalPresets/PresetContextMenu.tsx | 28 ++++ .../TerminalPresets/TerminalPresets.tsx | 116 +++++++++++++++ .../Sidebar/TabsView/TerminalPresets/index.ts | 1 + .../WorkspaceView/Sidebar/TabsView/index.tsx | 15 ++ .../src/renderer/stores/preset-modal.ts | 41 +++++ apps/desktop/src/shared/types.ts | 8 + 11 files changed, 453 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/PresetModal/PresetModal.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/PresetModal/index.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TerminalPresets/PresetContextMenu.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TerminalPresets/TerminalPresets.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TerminalPresets/index.ts create mode 100644 apps/desktop/src/renderer/stores/preset-modal.ts diff --git a/apps/desktop/src/lib/trpc/routers/config/config.ts b/apps/desktop/src/lib/trpc/routers/config/config.ts index 8a925af2252..211112d7d48 100644 --- a/apps/desktop/src/lib/trpc/routers/config/config.ts +++ b/apps/desktop/src/lib/trpc/routers/config/config.ts @@ -1,6 +1,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { db } from "main/lib/db"; +import type { SetupConfig } from "shared/types"; import { z } from "zod"; import { publicProcedure, router } from "../.."; @@ -99,6 +100,78 @@ export const createConfigRouter = () => { return { content: null, exists: false }; } }), + + // Get terminal presets for a project + getTerminalPresets: publicProcedure + .input(z.object({ projectId: z.string() })) + .query(({ input }) => { + const project = db.data.projects.find((p) => p.id === input.projectId); + if (!project) return []; + + const configPath = getConfigPath(project.mainRepoPath); + if (!existsSync(configPath)) return []; + + try { + const content = readFileSync(configPath, "utf-8"); + const config = JSON.parse(content) as SetupConfig; + return config.terminalPresets || []; + } catch { + return []; + } + }), + + // Save a new terminal preset + saveTerminalPreset: publicProcedure + .input( + z.object({ + projectId: z.string(), + preset: z.object({ + name: z.string(), + cwd: z.string().optional(), + commands: z.union([z.string(), z.array(z.string())]), + }), + }), + ) + .mutation(({ input }) => { + const project = db.data.projects.find((p) => p.id === input.projectId); + if (!project) throw new Error("Project not found"); + + const configPath = ensureConfigExists(project.mainRepoPath); + const content = readFileSync(configPath, "utf-8"); + const config = JSON.parse(content) as SetupConfig; + + config.terminalPresets = config.terminalPresets || []; + config.terminalPresets.push(input.preset); + + writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8"); + return { success: true }; + }), + + // Delete a terminal preset by name + deleteTerminalPreset: publicProcedure + .input( + z.object({ + projectId: z.string(), + presetName: z.string(), + }), + ) + .mutation(({ input }) => { + const project = db.data.projects.find((p) => p.id === input.projectId); + if (!project) throw new Error("Project not found"); + + const configPath = getConfigPath(project.mainRepoPath); + if (!existsSync(configPath)) return { success: false }; + + const content = readFileSync(configPath, "utf-8"); + const config = JSON.parse(content) as SetupConfig; + + config.terminalPresets = (config.terminalPresets || []).filter( + (p) => p.name !== input.presetName, + ); + + writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8"); + return { success: true }; + }), }); }; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabContentContextMenu.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabContentContextMenu.tsx index 9adfed6140c..502d5a8206a 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabContentContextMenu.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabContentContextMenu.tsx @@ -5,7 +5,7 @@ import { ContextMenuSeparator, ContextMenuTrigger, } from "@superset/ui/context-menu"; -import { Columns2, Rows2, X } from "lucide-react"; +import { Bookmark, Columns2, Rows2, X } from "lucide-react"; import type { ReactNode } from "react"; interface TabContentContextMenuProps { @@ -13,6 +13,7 @@ interface TabContentContextMenuProps { onSplitHorizontal: () => void; onSplitVertical: () => void; onClosePane: () => void; + onSaveAsPreset?: () => void; } export function TabContentContextMenu({ @@ -20,6 +21,7 @@ export function TabContentContextMenu({ onSplitHorizontal, onSplitVertical, onClosePane, + onSaveAsPreset, }: TabContentContextMenuProps) { return ( @@ -33,6 +35,15 @@ export function TabContentContextMenu({ Split Vertically + {onSaveAsPreset && ( + <> + + + + Save as Preset + + + )} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/WindowView/WindowPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/WindowView/WindowPane.tsx index c71da30e041..4f1a1b555f2 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/WindowView/WindowPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/WindowView/WindowPane.tsx @@ -3,6 +3,8 @@ import { HiMiniXMark } from "react-icons/hi2"; import { TbLayoutColumns, TbLayoutRows } from "react-icons/tb"; import type { MosaicBranch } from "react-mosaic-component"; import { MosaicWindow } from "react-mosaic-component"; +import { trpc } from "renderer/lib/trpc"; +import { useOpenPresetModal } from "renderer/stores/preset-modal"; import { registerPaneRef, unregisterPaneRef, @@ -57,6 +59,10 @@ export function WindowPane({ const [splitOrientation, setSplitOrientation] = useState("vertical"); + const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); + const { data: terminalSession } = trpc.terminal.getSession.useQuery(paneId); + const openPresetModal = useOpenPresetModal(); + useEffect(() => { const container = containerRef.current; if (container) { @@ -111,6 +117,15 @@ export function WindowPane({ ); + const handleSaveAsPreset = () => { + const projectId = activeWorkspace?.projectId; + if (!projectId) return; + + // Use terminal session cwd if available + const cwd = terminalSession?.cwd; + openPresetModal(projectId, cwd); + }; + return ( path={path} @@ -141,6 +156,9 @@ export function WindowPane({ onSplitHorizontal={() => splitPaneHorizontal(windowId, paneId, path)} onSplitVertical={() => splitPaneVertical(windowId, paneId, path)} onClosePane={() => removePane(paneId)} + onSaveAsPreset={ + activeWorkspace?.projectId ? handleSaveAsPreset : undefined + } > {/* biome-ignore lint/a11y/useKeyWithClickEvents lint/a11y/noStaticElementInteractions: Terminal handles its own keyboard events and focus */}
{ + utils.config.getTerminalPresets.invalidate(); + closeModal(); + }, + }); + + // Reset form when modal opens/closes + useEffect(() => { + if (isOpen) { + setName(""); + setCwd(prefillCwd || ""); + setCommands(""); + } + }, [isOpen, prefillCwd]); + + const handleSave = () => { + if (!projectId || !name.trim() || !commands.trim()) return; + + // Split commands by newline and filter empty lines + const commandList = commands + .split("\n") + .map((c) => c.trim()) + .filter((c) => c.length > 0); + + savePresetMutation.mutate({ + projectId, + preset: { + name: name.trim(), + cwd: cwd.trim() || undefined, + commands: commandList.length === 1 ? commandList[0] : commandList, + }, + }); + }; + + const isValid = name.trim().length > 0 && commands.trim().length > 0; + + return ( + !open && closeModal()}> + + + Create Terminal Preset + + Save a terminal configuration for quick access from the sidebar. + + + +
+
+ + setName(e.target.value)} + autoFocus + /> +
+ +
+ + setCwd(e.target.value)} + /> +

+ Relative to project root or absolute path +

+
+ +
+ +