diff --git a/apps/desktop/src/lib/trpc/routers/settings/index.ts b/apps/desktop/src/lib/trpc/routers/settings/index.ts index cd73a7b3bd9..cc4d47661bd 100644 --- a/apps/desktop/src/lib/trpc/routers/settings/index.ts +++ b/apps/desktop/src/lib/trpc/routers/settings/index.ts @@ -1,4 +1,5 @@ import { + EXECUTION_MODES, settings, TERMINAL_LINK_BEHAVIORS, type TerminalPreset, @@ -43,6 +44,7 @@ export const createSettingsRouter = () => { description: z.string().optional(), cwd: z.string(), commands: z.array(z.string()), + executionMode: z.enum(EXECUTION_MODES).optional(), }), ) .mutation(({ input }) => { @@ -76,6 +78,7 @@ export const createSettingsRouter = () => { description: z.string().optional(), cwd: z.string().optional(), commands: z.array(z.string()).optional(), + executionMode: z.enum(EXECUTION_MODES).optional(), }), }), ) @@ -97,6 +100,8 @@ export const createSettingsRouter = () => { if (input.patch.cwd !== undefined) preset.cwd = input.patch.cwd; if (input.patch.commands !== undefined) preset.commands = input.patch.commands; + if (input.patch.executionMode !== undefined) + preset.executionMode = input.patch.executionMode; localDb .insert(settings) 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 0413a486cc8..2279cdb9478 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 @@ -1,4 +1,8 @@ -import type { TerminalLinkBehavior, TerminalPreset } from "@superset/local-db"; +import type { + ExecutionMode, + TerminalLinkBehavior, + TerminalPreset, +} from "@superset/local-db"; import { AlertDialog, AlertDialogContent, @@ -20,7 +24,11 @@ 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 { HiOutlineCheck, HiOutlinePlus } from "react-icons/hi2"; +import { + HiOutlineCheck, + HiOutlinePlus, + HiOutlineQuestionMarkCircle, +} from "react-icons/hi2"; import { getPresetIcon, useIsDarkTheme, @@ -219,6 +227,20 @@ export function TerminalSettings({ visibleItems }: TerminalSettingsProps) { }); }; + const handleExecutionModeChange = (rowIndex: number, mode: ExecutionMode) => { + const preset = localPresets[rowIndex]; + if (!preset) return; + + setLocalPresets((prev) => + prev.map((p, i) => (i === rowIndex ? { ...p, executionMode: mode } : p)), + ); + + updatePreset.mutate({ + id: preset.id, + patch: { executionMode: mode }, + }); + }; + const handleAddRow = () => { createPreset.mutate({ name: "", @@ -423,7 +445,7 @@ export function TerminalSettings({ visibleItems }: TerminalSettingsProps) { }; return ( -
+

Terminal

@@ -508,6 +530,25 @@ export function TerminalSettings({ visibleItems }: TerminalSettingsProps) { {column.label}

))} + + +
+ Mode + +
+
+ +

Execution Mode

+

+ Sequential: Commands run one after + another in a single terminal (joined with &&) +

+

+ Parallel: Each command runs in its own + split pane within a single tab +

+
+
Actions
@@ -532,6 +573,7 @@ export function TerminalSettings({ visibleItems }: TerminalSettingsProps) { onBlur={handleCellBlur} onCommandsChange={handleCommandsChange} onCommandsBlur={handleCommandsBlur} + onExecutionModeChange={handleExecutionModeChange} onDelete={handleDeleteRow} onSetDefault={handleSetDefault} /> 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 5ce8e1fd86a..d2a3b20c923 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 @@ -1,5 +1,13 @@ +import { EXECUTION_MODES, type ExecutionMode } from "@superset/local-db"; import { Button } from "@superset/ui/button"; import { Input } from "@superset/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@superset/ui/select"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { HiOutlineStar, HiStar } from "react-icons/hi2"; import { LuTrash } from "react-icons/lu"; @@ -63,6 +71,7 @@ interface PresetRowProps { onBlur: (rowIndex: number, column: PresetColumnKey) => void; onCommandsChange: (rowIndex: number, commands: string[]) => void; onCommandsBlur: (rowIndex: number) => void; + onExecutionModeChange: (rowIndex: number, mode: ExecutionMode) => void; onDelete: (rowIndex: number) => void; onSetDefault: (presetId: string | null) => void; } @@ -75,6 +84,7 @@ export function PresetRow({ onBlur, onCommandsChange, onCommandsBlur, + onExecutionModeChange, onDelete, onSetDefault, }: PresetRowProps) { @@ -102,6 +112,24 @@ export function PresetRow({ />
))} +
+ +
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx index d750c7fa23f..83a48876cff 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx @@ -88,13 +88,25 @@ export function GroupStrip() { addTab(activeWorkspaceId); }; + const addTabWithMultiplePanes = useTabsStore( + (s) => s.addTabWithMultiplePanes, + ); + const handleSelectPreset = (preset: TerminalPreset) => { if (!activeWorkspaceId) return; - const { tabId } = addTab(activeWorkspaceId, { - initialCommands: preset.commands, - initialCwd: preset.cwd || undefined, - }); + const isParallel = + preset.executionMode === "parallel" && preset.commands.length > 1; + + const { tabId } = isParallel + ? addTabWithMultiplePanes(activeWorkspaceId, { + commands: preset.commands, + initialCwd: preset.cwd || undefined, + }) + : addTab(activeWorkspaceId, { + initialCommands: preset.commands, + initialCwd: preset.cwd || undefined, + }); if (preset.name) { renameTab(tabId, preset.name); diff --git a/apps/desktop/src/renderer/stores/tabs/store.ts b/apps/desktop/src/renderer/stores/tabs/store.ts index bdb8ca25e62..997c73179e6 100644 --- a/apps/desktop/src/renderer/stores/tabs/store.ts +++ b/apps/desktop/src/renderer/stores/tabs/store.ts @@ -4,13 +4,21 @@ import { trpcTabsStorage } from "renderer/lib/trpc-storage"; import { create } from "zustand"; import { devtools, persist } from "zustand/middleware"; import { movePaneToNewTab, movePaneToTab } from "./actions/move-pane"; -import type { AddFileViewerPaneOptions, TabsState, TabsStore } from "./types"; +import type { + AddFileViewerPaneOptions, + AddTabWithMultiplePanesOptions, + TabsState, + TabsStore, +} from "./types"; import { + buildMultiPaneLayout, type CreatePaneOptions, createFileViewerPane, createPane, createTabWithPane, extractPaneIdsFromLayout, + generateId, + generateTabName, getAdjacentPaneId, getFirstPaneId, getPaneIdsForTab, @@ -121,6 +129,68 @@ export const useTabsStore = create()( return { tabId: tab.id, paneId: pane.id }; }, + addTabWithMultiplePanes: ( + workspaceId: string, + options: AddTabWithMultiplePanesOptions, + ) => { + const state = get(); + const tabId = generateId("tab"); + const panes: ReturnType[] = options.commands.map( + (command) => + createPane(tabId, "terminal", { + initialCommands: [command], + initialCwd: options.initialCwd, + }), + ); + + const paneIds = panes.map((p) => p.id); + const layout = buildMultiPaneLayout(paneIds); + const workspaceTabs = state.tabs.filter( + (t) => t.workspaceId === workspaceId, + ); + + const tab = { + id: tabId, + name: generateTabName(workspaceTabs), + workspaceId, + layout, + createdAt: Date.now(), + }; + + const panesRecord: Record = {}; + for (const pane of panes) { + panesRecord[pane.id] = pane; + } + + const currentActiveId = state.activeTabIds[workspaceId]; + const historyStack = state.tabHistoryStacks[workspaceId] || []; + const newHistoryStack = currentActiveId + ? [ + currentActiveId, + ...historyStack.filter((id) => id !== currentActiveId), + ] + : historyStack; + + set({ + tabs: [...state.tabs, tab], + panes: { ...state.panes, ...panesRecord }, + activeTabIds: { + ...state.activeTabIds, + [workspaceId]: tab.id, + }, + focusedPaneIds: { + ...state.focusedPaneIds, + [tab.id]: paneIds[0], + }, + tabHistoryStacks: { + ...state.tabHistoryStacks, + [workspaceId]: newHistoryStack, + }, + }); + + return { tabId: tab.id, paneIds }; + }, + removeTab: (tabId) => { const state = get(); const tabToRemove = state.tabs.find((t) => t.id === tabId); diff --git a/apps/desktop/src/renderer/stores/tabs/types.ts b/apps/desktop/src/renderer/stores/tabs/types.ts index 628f2a4bdfb..1b7d332e3a6 100644 --- a/apps/desktop/src/renderer/stores/tabs/types.ts +++ b/apps/desktop/src/renderer/stores/tabs/types.ts @@ -35,6 +35,11 @@ export interface AddTabOptions { initialCwd?: string; } +export interface AddTabWithMultiplePanesOptions { + commands: string[]; + initialCwd?: string; +} + /** * Options for opening a file in a file-viewer pane */ @@ -60,6 +65,10 @@ export interface TabsStore extends TabsState { workspaceId: string, options?: AddTabOptions, ) => { tabId: string; paneId: string }; + addTabWithMultiplePanes: ( + workspaceId: string, + options: AddTabWithMultiplePanesOptions, + ) => { tabId: string; paneIds: string[] }; removeTab: (tabId: string) => void; renameTab: (tabId: string, newName: string) => void; setTabAutoTitle: (tabId: string, title: string) => void; diff --git a/apps/desktop/src/renderer/stores/tabs/useTabsWithPresets.ts b/apps/desktop/src/renderer/stores/tabs/useTabsWithPresets.ts index 4d102105fa4..d27acf01b63 100644 --- a/apps/desktop/src/renderer/stores/tabs/useTabsWithPresets.ts +++ b/apps/desktop/src/renderer/stores/tabs/useTabsWithPresets.ts @@ -13,13 +13,15 @@ export function useTabsWithPresets() { const { defaultPreset } = usePresets(); const storeAddTab = useTabsStore((s) => s.addTab); + const storeAddTabWithMultiplePanes = useTabsStore( + (s) => s.addTabWithMultiplePanes, + ); const storeAddPane = useTabsStore((s) => s.addPane); const storeSplitPaneVertical = useTabsStore((s) => s.splitPaneVertical); const storeSplitPaneHorizontal = useTabsStore((s) => s.splitPaneHorizontal); const storeSplitPaneAuto = useTabsStore((s) => s.splitPaneAuto); const renameTab = useTabsStore((s) => s.renameTab); - // Get preset options if a default preset is set const defaultPresetOptions: AddTabOptions | undefined = useMemo(() => { if (!defaultPreset) return undefined; return { @@ -28,24 +30,50 @@ export function useTabsWithPresets() { }; }, [defaultPreset]); - // Wrapped addTab that applies default preset + const shouldUseParallelMode = useMemo(() => { + return ( + defaultPreset?.executionMode === "parallel" && + defaultPreset.commands.length > 1 + ); + }, [defaultPreset]); + const addTab = useCallback( (workspaceId: string, options?: AddTabOptions) => { - // If explicit options are provided, use them; otherwise use default preset - const effectiveOptions = options ?? defaultPresetOptions; - const result = storeAddTab(workspaceId, effectiveOptions); + if (options) { + return storeAddTab(workspaceId, options); + } + + if (shouldUseParallelMode && defaultPreset) { + const { tabId, paneIds } = storeAddTabWithMultiplePanes(workspaceId, { + commands: defaultPreset.commands, + initialCwd: defaultPreset.cwd || undefined, + }); + + if (defaultPreset.name) { + renameTab(tabId, defaultPreset.name); + } + + return { tabId, paneId: paneIds[0] }; + } + + const result = storeAddTab(workspaceId, defaultPresetOptions); - // If using default preset and it has a name, rename the tab - if (!options && defaultPreset?.name) { + if (defaultPreset?.name) { renameTab(result.tabId, defaultPreset.name); } return result; }, - [storeAddTab, defaultPresetOptions, defaultPreset, renameTab], + [ + storeAddTab, + storeAddTabWithMultiplePanes, + defaultPresetOptions, + defaultPreset, + shouldUseParallelMode, + renameTab, + ], ); - // Wrapped addPane that applies default preset const addPane = useCallback( (tabId: string, options?: AddTabOptions) => { const effectiveOptions = options ?? defaultPresetOptions; @@ -54,7 +82,6 @@ export function useTabsWithPresets() { [storeAddPane, defaultPresetOptions], ); - // Wrapped splitPaneVertical that applies default preset const splitPaneVertical = useCallback( ( tabId: string, @@ -73,7 +100,6 @@ export function useTabsWithPresets() { [storeSplitPaneVertical, defaultPresetOptions], ); - // Wrapped splitPaneHorizontal that applies default preset const splitPaneHorizontal = useCallback( ( tabId: string, @@ -92,7 +118,6 @@ export function useTabsWithPresets() { [storeSplitPaneHorizontal, defaultPresetOptions], ); - // Wrapped splitPaneAuto that applies default preset const splitPaneAuto = useCallback( ( tabId: string, diff --git a/apps/desktop/src/renderer/stores/tabs/utils.test.ts b/apps/desktop/src/renderer/stores/tabs/utils.test.ts index 29b308d5010..240e150b081 100644 --- a/apps/desktop/src/renderer/stores/tabs/utils.test.ts +++ b/apps/desktop/src/renderer/stores/tabs/utils.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test"; import type { MosaicNode } from "react-mosaic-component"; import type { Tab } from "./types"; import { + buildMultiPaneLayout, findPanePath, getAdjacentPaneId, resolveActiveTabIdForWorkspace, @@ -290,3 +291,112 @@ describe("resolveActiveTabIdForWorkspace", () => { ).toBeNull(); }); }); + +describe("buildMultiPaneLayout", () => { + it("throws error for empty pane array", () => { + expect(() => buildMultiPaneLayout([])).toThrow( + "Cannot build layout with zero panes", + ); + }); + + it("returns leaf node for single pane", () => { + const result = buildMultiPaneLayout(["pane-1"]); + expect(result).toBe("pane-1"); + }); + + it("returns horizontal split for two panes", () => { + const result = buildMultiPaneLayout(["pane-1", "pane-2"]); + expect(result).toEqual({ + direction: "row", + first: "pane-1", + second: "pane-2", + splitPercentage: 50, + }); + }); + + it("returns balanced grid for three panes", () => { + const result = buildMultiPaneLayout(["pane-1", "pane-2", "pane-3"]); + expect(result).toEqual({ + direction: "column", + first: { + direction: "row", + first: "pane-1", + second: "pane-2", + splitPercentage: 50, + }, + second: "pane-3", + splitPercentage: 50, + }); + }); + + it("returns 2x2 grid for four panes", () => { + const result = buildMultiPaneLayout([ + "pane-1", + "pane-2", + "pane-3", + "pane-4", + ]); + expect(result).toEqual({ + direction: "column", + first: { + direction: "row", + first: "pane-1", + second: "pane-2", + splitPercentage: 50, + }, + second: { + direction: "row", + first: "pane-3", + second: "pane-4", + splitPercentage: 50, + }, + splitPercentage: 50, + }); + }); + + it("returns balanced nested layout for five panes", () => { + const result = buildMultiPaneLayout([ + "pane-1", + "pane-2", + "pane-3", + "pane-4", + "pane-5", + ]); + expect(result).toEqual({ + direction: "column", + first: { + direction: "row", + first: { + direction: "row", + first: "pane-1", + second: "pane-2", + splitPercentage: 50, + }, + second: "pane-3", + splitPercentage: 50, + }, + second: { + direction: "row", + first: "pane-4", + second: "pane-5", + splitPercentage: 50, + }, + splitPercentage: 50, + }); + }); + + it("returns row-first layout when direction is row", () => { + const result = buildMultiPaneLayout(["pane-1", "pane-2", "pane-3"], "row"); + expect(result).toEqual({ + direction: "row", + first: { + direction: "row", + first: "pane-1", + second: "pane-2", + splitPercentage: 50, + }, + second: "pane-3", + splitPercentage: 50, + }); + }); +}); diff --git a/apps/desktop/src/renderer/stores/tabs/utils.ts b/apps/desktop/src/renderer/stores/tabs/utils.ts index 4706642f0aa..a728e1f4c55 100644 --- a/apps/desktop/src/renderer/stores/tabs/utils.ts +++ b/apps/desktop/src/renderer/stores/tabs/utils.ts @@ -439,6 +439,42 @@ export const addPaneToLayout = ( splitPercentage: 50, }); +/** + * Builds a balanced multi-pane Mosaic layout using recursive binary splits. + * For 3+ panes, alternates between column and row splits to create a grid. + */ +export const buildMultiPaneLayout = ( + paneIds: string[], + direction: "row" | "column" = "column", +): MosaicNode => { + if (paneIds.length === 0) { + throw new Error("Cannot build layout with zero panes"); + } + + if (paneIds.length === 1) { + return paneIds[0]; + } + + if (paneIds.length === 2) { + return { + direction: "row", + first: paneIds[0], + second: paneIds[1], + splitPercentage: 50, + }; + } + + const mid = Math.ceil(paneIds.length / 2); + const nextDirection = direction === "column" ? "row" : "column"; + + return { + direction, + first: buildMultiPaneLayout(paneIds.slice(0, mid), nextDirection), + second: buildMultiPaneLayout(paneIds.slice(mid), nextDirection), + splitPercentage: 50, + }; +}; + /** * Updates the history stack when switching to a new active tab * Adds the current active to history and removes the new active from history diff --git a/packages/local-db/src/schema/zod.ts b/packages/local-db/src/schema/zod.ts index 1aa69d75af2..6c0374c4b29 100644 --- a/packages/local-db/src/schema/zod.ts +++ b/packages/local-db/src/schema/zod.ts @@ -47,6 +47,10 @@ export const gitHubStatusSchema = z.object({ export type GitHubStatus = z.infer; +export const EXECUTION_MODES = ["sequential", "parallel"] as const; + +export type ExecutionMode = (typeof EXECUTION_MODES)[number]; + /** * Terminal preset */ @@ -57,6 +61,7 @@ export const terminalPresetSchema = z.object({ cwd: z.string(), commands: z.array(z.string()), isDefault: z.boolean().optional(), + executionMode: z.enum(EXECUTION_MODES).optional(), }); export type TerminalPreset = z.infer;