diff --git a/apps/desktop/src/lib/trpc/routers/settings/index.ts b/apps/desktop/src/lib/trpc/routers/settings/index.ts index 7a871239483..a618a8d0d87 100644 --- a/apps/desktop/src/lib/trpc/routers/settings/index.ts +++ b/apps/desktop/src/lib/trpc/routers/settings/index.ts @@ -407,6 +407,7 @@ export const createSettingsRouter = () => { commands: z.array(z.string()), projectIds: z.array(z.string()).nullable().optional(), pinnedToBar: z.boolean().optional(), + useAsWorkspaceRun: z.boolean().optional(), executionMode: z.enum(EXECUTION_MODES).optional(), }), ) @@ -437,6 +438,7 @@ export const createSettingsRouter = () => { commands: z.array(z.string()).optional(), projectIds: z.array(z.string()).nullable().optional(), pinnedToBar: z.boolean().optional(), + useAsWorkspaceRun: z.boolean().optional(), executionMode: z.enum(EXECUTION_MODES).optional(), }), }), @@ -462,6 +464,8 @@ export const createSettingsRouter = () => { preset.projectIds = normalizePresetProjectIds(input.patch.projectIds); if (input.patch.pinnedToBar !== undefined) preset.pinnedToBar = input.patch.pinnedToBar; + if (input.patch.useAsWorkspaceRun !== undefined) + preset.useAsWorkspaceRun = input.patch.useAsWorkspaceRun; if (input.patch.executionMode !== undefined) preset.executionMode = input.patch.executionMode; diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts index 2bd22f3e28f..dc2060f123b 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts @@ -1,5 +1,6 @@ import { projects, + settings, workspaceSections, workspaces, worktrees, @@ -7,8 +8,13 @@ import { import { TRPCError } from "@trpc/server"; import { eq, isNotNull, isNull } from "drizzle-orm"; import { localDb } from "main/lib/local-db"; +import { selectWorkspaceRunDefinition } from "shared/workspace-run-definition"; import { z } from "zod"; import { publicProcedure, router } from "../../.."; +import { + normalizeTerminalPresets, + type PresetWithUnknownMode, +} from "../../settings/preset-execution-mode"; import { getWorkspace } from "../utils/db-helpers"; import { getProjectChildItems } from "../utils/project-children-order"; import { loadSetupConfig } from "../utils/setup"; @@ -17,6 +23,64 @@ import { getWorkspacePath } from "../utils/worktree"; type WorktreePathMap = Map; +function getTerminalPresetsForWorkspaceRun() { + const row = localDb.select().from(settings).get(); + return normalizeTerminalPresets( + (row?.terminalPresets ?? []) as PresetWithUnknownMode[], + ); +} + +function getWorkspaceRunDefinition(workspaceId: string) { + const workspace = localDb + .select() + .from(workspaces) + .where(eq(workspaces.id, workspaceId)) + .get(); + if (!workspace) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Workspace ${workspaceId} not found`, + }); + } + + const project = localDb + .select() + .from(projects) + .where(eq(projects.id, workspace.projectId)) + .get(); + if (!project) { + return null; + } + + const worktree = workspace.worktreeId + ? localDb + .select() + .from(worktrees) + .where(eq(worktrees.id, workspace.worktreeId)) + .get() + : null; + + const worktreePath = + workspace.type === "worktree" && worktree?.path + ? worktree.path + : workspace.type === "branch" + ? project.mainRepoPath + : undefined; + + const config = loadSetupConfig({ + mainRepoPath: project.mainRepoPath, + worktreePath, + projectId: project.id, + }); + + return selectWorkspaceRunDefinition({ + presets: getTerminalPresetsForWorkspaceRun(), + configRunCommands: config?.run, + configCwd: config?.cwd, + projectId: project.id, + }); +} + /** Returns workspace IDs in sidebar visual order (by project.tabOrder, then ungrouped workspaces, then sections by tabOrder). */ function getWorkspacesInVisualOrder(): string[] { const activeProjects = localDb @@ -302,51 +366,14 @@ export const createQueryProcedures = () => { getResolvedRunCommands: publicProcedure .input(z.object({ workspaceId: z.string() })) .query(({ input }) => { - const workspace = localDb - .select() - .from(workspaces) - .where(eq(workspaces.id, input.workspaceId)) - .get(); - if (!workspace) { - throw new TRPCError({ - code: "NOT_FOUND", - message: `Workspace ${input.workspaceId} not found`, - }); - } - - const project = localDb - .select() - .from(projects) - .where(eq(projects.id, workspace.projectId)) - .get(); - if (!project) { - return { commands: [] }; - } - - const worktree = workspace.worktreeId - ? localDb - .select() - .from(worktrees) - .where(eq(worktrees.id, workspace.worktreeId)) - .get() - : null; - - const worktreePath = - workspace.type === "worktree" && worktree?.path - ? worktree.path - : workspace.type === "branch" - ? project.mainRepoPath - : undefined; - - const config = loadSetupConfig({ - mainRepoPath: project.mainRepoPath, - worktreePath, - projectId: project.id, - }); - + const definition = getWorkspaceRunDefinition(input.workspaceId); return { - commands: config?.run ?? [], + commands: definition?.commands ?? [], }; }), + + getWorkspaceRunDefinition: publicProcedure + .input(z.object({ workspaceId: z.string() })) + .query(({ input }) => getWorkspaceRunDefinition(input.workspaceId)), }); }; diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.test.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.test.ts index fc1e36beac0..ae2291def6d 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.test.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.test.ts @@ -655,6 +655,26 @@ describe("run config", () => { expect(config).toBeNull(); }); + test("validates cwd field must be non-empty", () => { + writeFileSync( + join(MAIN_REPO, ".superset", "config.json"), + JSON.stringify({ cwd: " ", run: ["bun dev"] }), + ); + + const config = loadSetupConfig({ mainRepoPath: MAIN_REPO }); + expect(config).toBeNull(); + }); + + test("normalizes configured cwd", () => { + writeFileSync( + join(MAIN_REPO, ".superset", "config.json"), + JSON.stringify({ cwd: " packages/web ", run: ["bun dev"] }), + ); + + const config = loadSetupConfig({ mainRepoPath: MAIN_REPO }); + expect(config?.cwd).toBe("packages/web"); + }); + test("local config can override run commands", () => { writeFileSync( join(MAIN_REPO, ".superset", "config.json"), diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.ts index 9164544273e..0596349849e 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.ts @@ -53,6 +53,13 @@ function readConfigFile(configPath: string): SetupConfig | null { throw new Error("'run' field must be an array of strings"); } + if (parsed.cwd !== undefined) { + if (typeof parsed.cwd !== "string" || parsed.cwd.trim().length === 0) { + throw new Error("'cwd' field must be a non-empty string"); + } + parsed.cwd = parsed.cwd.trim(); + } + return parsed; } catch (error) { console.error( @@ -124,6 +131,7 @@ function mergeBaseConfigs( setup: override.setup ?? base.setup, teardown: override.teardown ?? base.teardown, run: override.run ?? base.run, + cwd: override.cwd ?? base.cwd, }; } diff --git a/apps/desktop/src/renderer/lib/project-scripts.ts b/apps/desktop/src/renderer/lib/project-scripts.ts index a0b93851d14..b52a30d6509 100644 --- a/apps/desktop/src/renderer/lib/project-scripts.ts +++ b/apps/desktop/src/renderer/lib/project-scripts.ts @@ -9,6 +9,7 @@ export async function invalidateProjectScriptQueries( await Promise.all([ utils.config.getConfigContent.invalidate({ projectId }), utils.config.shouldShowSetupCard.invalidate({ projectId }), + utils.workspaces.getWorkspaceRunDefinition.invalidate(), utils.workspaces.getResolvedRunCommands.invalidate(), ]); } diff --git a/apps/desktop/src/renderer/react-query/presets/index.ts b/apps/desktop/src/renderer/react-query/presets/index.ts index 7ce1e6e20b4..fc02146c780 100644 --- a/apps/desktop/src/renderer/react-query/presets/index.ts +++ b/apps/desktop/src/renderer/react-query/presets/index.ts @@ -15,6 +15,8 @@ function useCreateTerminalPreset( await utils.settings.getTerminalPresets.invalidate(); await utils.settings.getWorkspaceCreationPresets.invalidate(); await utils.settings.getNewTabPresets.invalidate(); + await utils.workspaces.getWorkspaceRunDefinition.invalidate(); + await utils.workspaces.getResolvedRunCommands.invalidate(); await options?.onSuccess?.(...args); }, }); @@ -33,6 +35,8 @@ function useUpdateTerminalPreset( await utils.settings.getTerminalPresets.invalidate(); await utils.settings.getWorkspaceCreationPresets.invalidate(); await utils.settings.getNewTabPresets.invalidate(); + await utils.workspaces.getWorkspaceRunDefinition.invalidate(); + await utils.workspaces.getResolvedRunCommands.invalidate(); await options?.onSuccess?.(...args); }, }); @@ -51,6 +55,8 @@ function useDeleteTerminalPreset( await utils.settings.getTerminalPresets.invalidate(); await utils.settings.getWorkspaceCreationPresets.invalidate(); await utils.settings.getNewTabPresets.invalidate(); + await utils.workspaces.getWorkspaceRunDefinition.invalidate(); + await utils.workspaces.getResolvedRunCommands.invalidate(); await options?.onSuccess?.(...args); }, }); @@ -69,6 +75,8 @@ function useSetPresetAutoApply( await utils.settings.getTerminalPresets.invalidate(); await utils.settings.getWorkspaceCreationPresets.invalidate(); await utils.settings.getNewTabPresets.invalidate(); + await utils.workspaces.getWorkspaceRunDefinition.invalidate(); + await utils.workspaces.getResolvedRunCommands.invalidate(); await options?.onSuccess?.(...args); }, }); @@ -87,6 +95,8 @@ function useReorderTerminalPresets( await utils.settings.getTerminalPresets.invalidate(); await utils.settings.getWorkspaceCreationPresets.invalidate(); await utils.settings.getNewTabPresets.invalidate(); + await utils.workspaces.getWorkspaceRunDefinition.invalidate(); + await utils.workspaces.getResolvedRunCommands.invalidate(); await options?.onSuccess?.(...args); }, }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/WorkspaceRunButton/WorkspaceRunButton.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/WorkspaceRunButton/WorkspaceRunButton.tsx index 26f9a84befb..d1e1ce736cf 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/WorkspaceRunButton/WorkspaceRunButton.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/WorkspaceRunButton/WorkspaceRunButton.tsx @@ -44,12 +44,12 @@ export const WorkspaceRunButton = memo(function WorkspaceRunButton({ workspaceId, worktreePath, }); - const { data: runConfig } = - electronTrpc.workspaces.getResolvedRunCommands.useQuery( + const { data: runDefinition } = + electronTrpc.workspaces.getWorkspaceRunDefinition.useQuery( { workspaceId }, { enabled: !!workspaceId }, ); - const hasRunCommand = (runConfig?.commands ?? []).some( + const hasRunCommand = (runDefinition?.commands ?? []).some( (command) => command.trim().length > 0, ); @@ -73,13 +73,20 @@ export const WorkspaceRunButton = memo(function WorkspaceRunButton({ ]); const handleConfigureClick = useCallback(() => { + if (runDefinition?.source === "terminal-preset") { + void navigate({ + to: "/settings/terminal", + search: { editPresetId: runDefinition.presetId }, + }); + return; + } if (!projectId) return; setSettingsSearchQuery("scripts"); void navigate({ to: "/settings/projects/$projectId", params: { projectId }, }); - }, [navigate, projectId, setSettingsSearchQuery]); + }, [navigate, projectId, runDefinition, setSettingsSearchQuery]); const handleForceStopClick = useCallback(() => { void forceStopWorkspaceRun(); @@ -167,7 +174,9 @@ export const WorkspaceRunButton = memo(function WorkspaceRunButton({ )} - Configure + {runDefinition?.source === "terminal-preset" + ? "Edit Run Preset" + : "Configure"} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2PresetsBar/V2PresetsBar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2PresetsBar/V2PresetsBar.tsx index e3352bd9cca..53c68c86b2e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2PresetsBar/V2PresetsBar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2PresetsBar/V2PresetsBar.tsx @@ -10,7 +10,13 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { useNavigate } from "@tanstack/react-router"; import { Eye, EyeOff, Settings } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { + type ReactNode, + useCallback, + useEffect, + useMemo, + useState, +} from "react"; import { HiMiniCommandLine } from "react-icons/hi2"; import { useIsDarkTheme } from "renderer/assets/app-icons/preset-icons"; import { HotkeyMenuShortcut } from "renderer/components/HotkeyMenuShortcut"; @@ -27,6 +33,7 @@ interface V2PresetsBarProps { executePreset: (preset: V2TerminalPresetRow) => void | Promise; showPresetsBar: boolean; onToggleShowPresetsBar: (enabled: boolean) => void; + trailing?: ReactNode; } // Co-located to keep v2 self-contained. Mirrors the v1 array in @@ -68,6 +75,7 @@ export function V2PresetsBar({ executePreset, showPresetsBar, onToggleShowPresetsBar, + trailing, }: V2PresetsBarProps) { const navigate = useNavigate(); const isDark = useIsDarkTheme(); @@ -194,14 +202,18 @@ export function V2PresetsBar({ return (
- @@ -269,6 +281,9 @@ export function V2PresetsBar({ + {visiblePresets.length > 0 ? ( +
+ ) : null} {visiblePresets.map(({ preset }, visibleIndex) => { const hotkeyId = PRESET_HOTKEY_IDS[visibleIndex]; return ( @@ -286,6 +301,9 @@ export function V2PresetsBar({ /> ); })} + {trailing ? ( +
{trailing}
+ ) : null}
); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2PresetsBar/components/V2PresetBarItem/V2PresetBarItem.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2PresetsBar/components/V2PresetBarItem/V2PresetBarItem.tsx index c09858c6c54..a954f9fc495 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2PresetsBar/components/V2PresetBarItem/V2PresetBarItem.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2PresetsBar/components/V2PresetBarItem/V2PresetBarItem.tsx @@ -92,14 +92,14 @@ export function V2PresetBarItem({ + + + + + + + {canForceStop && ( + <> + void onForceStop()} + className="text-destructive focus:text-destructive" + > + + Force Stop + + + + )} + + + {definition?.source === "terminal-preset" + ? "Edit Run Preset" + : "Configure"} + + + +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2WorkspaceRunButton/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2WorkspaceRunButton/index.ts new file mode 100644 index 00000000000..c238237950d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2WorkspaceRunButton/index.ts @@ -0,0 +1 @@ +export { V2WorkspaceRunButton } from "./V2WorkspaceRunButton"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx index 7cfa912b156..222983bcc3a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx @@ -9,6 +9,8 @@ import { } from "@superset/ui/dropdown-menu"; import { toast } from "@superset/ui/sonner"; import { workspaceTrpc } from "@superset/workspace-client"; +import { eq } from "@tanstack/db"; +import { useLiveQuery } from "@tanstack/react-db"; import { Check, ChevronDown, LoaderCircle, Plus, Trash2 } from "lucide-react"; import { useCallback, useMemo, useState, useSyncExternalStore } from "react"; import { markTerminalForBackground } from "renderer/lib/terminal/terminal-background-intents"; @@ -17,6 +19,7 @@ import type { PaneViewerData, TerminalPaneData, } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { getRelativeTime } from "renderer/screens/main/components/WorkspacesListView/utils"; import { TerminalPaneIcon } from "../TerminalPaneIcon"; @@ -76,6 +79,7 @@ export function TerminalSessionDropdown({ workspaceId, }: TerminalSessionDropdownProps) { const [isOpen, setIsOpen] = useState(false); + const collections = useCollections(); const { terminalId } = context.pane.data as TerminalPaneData; const terminalInstanceId = context.pane.id; const utils = workspaceTrpc.useUtils(); @@ -87,6 +91,18 @@ export function TerminalSessionDropdown({ refetchOnWindowFocus: true, }, ); + const { data: localWorkspaceRows = [] } = useLiveQuery( + (query) => + query + .from({ v2WorkspaceLocalState: collections.v2WorkspaceLocalState }) + .where(({ v2WorkspaceLocalState }) => + eq(v2WorkspaceLocalState.workspaceId, workspaceId), + ), + [collections, workspaceId], + ); + const workspaceRunTerminals = + localWorkspaceRows[0]?.workspaceRunTerminals ?? {}; + const workspaceRunState = workspaceRunTerminals[terminalId]?.state ?? null; const sessions = useMemo(() => { const liveSessions = sessionsQuery.data?.sessions ?? []; @@ -241,6 +257,18 @@ export function TerminalSessionDropdown({ onClick={(event) => event.stopPropagation()} > + {workspaceRunState && ( + + )} {triggerTitle} @@ -279,11 +307,13 @@ export function TerminalSessionDropdown({ const createdAtLabel = formatCreatedAt(session.createdAt); const status = isCurrent ? "Current" - : session.pending - ? "Starting" - : session.attached - ? "Attached" - : "Detached"; + : workspaceRunTerminals[session.terminalId] + ? "Run" + : session.pending + ? "Starting" + : session.attached + ? "Attached" + : "Detached"; const title = isCurrent ? triggerTitle : (session.title ?? location?.titleOverride ?? "Terminal"); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx index 48d5723f1d0..370c9780e8c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx @@ -21,6 +21,7 @@ import { getBaseName } from "renderer/lib/pathBasename"; import { consumeTerminalBackgroundIntent } from "renderer/lib/terminal/terminal-background-intents"; import { terminalRuntimeRegistry } from "renderer/lib/terminal/terminal-runtime-registry"; import { useWorkspace } from "renderer/routes/_authenticated/_dashboard/v2-workspace/providers/WorkspaceProvider"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { FileIcon } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils"; import { clearV2TerminalRunStatus, @@ -109,6 +110,7 @@ export function usePaneRegistry({ }: UsePaneRegistryOptions): PaneRegistry { const { workspace } = useWorkspace(); const workspaceId = workspace.id; + const collections = useCollections(); const clearShortcut = useHotkeyDisplay("CLEAR_TERMINAL").text; const scrollToBottomShortcut = useHotkeyDisplay("SCROLL_TO_BOTTOM").text; const workspaceTrpcUtils = workspaceTrpc.useUtils(); @@ -142,6 +144,16 @@ export function usePaneRegistry({ }); }, }); + const clearWorkspaceRunTerminal = useMemo( + () => (terminalId: string) => { + if (!collections.v2WorkspaceLocalState.get(workspaceId)) return; + collections.v2WorkspaceLocalState.update(workspaceId, (draft) => { + if (!draft.workspaceRunTerminals?.[terminalId]) return; + delete draft.workspaceRunTerminals[terminalId]; + }); + }, + [collections.v2WorkspaceLocalState, workspaceId], + ); return useMemo>( () => ({ @@ -264,6 +276,7 @@ export function usePaneRegistry({ return; } clearV2TerminalRunStatus(terminalId, workspaceId); + clearWorkspaceRunTerminal(terminalId); terminalRuntimeRegistry.dispose(terminalId); killTerminalSessionSilently({ terminalId, workspaceId }); }, @@ -482,6 +495,7 @@ export function usePaneRegistry({ }), [ workspaceId, + clearWorkspaceRunTerminal, clearShortcut, scrollToBottomShortcut, killTerminalSession, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2PresetExecution/useV2PresetExecution.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2PresetExecution/useV2PresetExecution.ts index 76b4def144a..b304ee22500 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2PresetExecution/useV2PresetExecution.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2PresetExecution/useV2PresetExecution.ts @@ -201,5 +201,5 @@ export function useV2PresetExecution({ [store, launcher, resolvePresetCommands], ); - return { matchedPresets, executePreset }; + return { matchedPresets, executePreset, resolvePresetCommands }; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2TerminalLauncher/useV2TerminalLauncher.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2TerminalLauncher/useV2TerminalLauncher.ts index ffaab2e70aa..5a5e9867357 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2TerminalLauncher/useV2TerminalLauncher.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2TerminalLauncher/useV2TerminalLauncher.ts @@ -14,6 +14,7 @@ interface CreateOptions { */ terminalId?: string; command?: string; + cwd?: string; } export interface TerminalLauncher { @@ -43,6 +44,7 @@ export function useV2TerminalLauncher(): TerminalLauncher { workspaceId, themeType, initialCommand: options?.command, + cwd: options?.cwd, }); return terminalId; }, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2WorkspaceRun/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2WorkspaceRun/index.ts new file mode 100644 index 00000000000..ce5fb8f7caa --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2WorkspaceRun/index.ts @@ -0,0 +1 @@ +export { useV2WorkspaceRun } from "./useV2WorkspaceRun"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2WorkspaceRun/useV2WorkspaceRun.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2WorkspaceRun/useV2WorkspaceRun.ts new file mode 100644 index 00000000000..de64eda3fd1 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2WorkspaceRun/useV2WorkspaceRun.ts @@ -0,0 +1,351 @@ +import type { CreatePaneInput, WorkspaceStore } from "@superset/panes"; +import { toast } from "@superset/ui/sonner"; +import { workspaceTrpc } from "@superset/workspace-client"; +import { eq } from "@tanstack/db"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useWorkspaceEvent } from "renderer/hooks/host-service/useWorkspaceEvent"; +import { buildTerminalCommand } from "renderer/lib/terminal/launch-command"; +import { useWorkspace } from "renderer/routes/_authenticated/_dashboard/v2-workspace/providers/WorkspaceProvider"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import type { + V2TerminalPresetRow, + WorkspaceRunTerminalState, +} from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal"; +import { selectWorkspaceRunDefinition } from "shared/workspace-run-definition"; +import type { StoreApi } from "zustand/vanilla"; +import type { PaneViewerData, TerminalPaneData } from "../../types"; +import type { TerminalLauncher } from "../useV2TerminalLauncher"; + +const CTRL_C_INPUT = "\u0003"; +const TERMINAL_GONE_ERROR_MESSAGES = [ + "Terminal session not found", + "Terminal session has exited", + "Terminal session does not belong to this workspace", +] as const; + +function isTerminalGoneError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return TERMINAL_GONE_ERROR_MESSAGES.some((terminalMessage) => + message.includes(terminalMessage), + ); +} + +function markStopped( + state: WorkspaceRunTerminalState, + stoppedAt: number, + overrides?: Partial< + Pick + >, +) { + state.state = + overrides?.state ?? + (state.stopRequestedAt ? "stopped-by-user" : "stopped-by-exit"); + state.stoppedAt = stoppedAt; + if (overrides?.exitCode !== undefined) state.exitCode = overrides.exitCode; + if (overrides?.signal !== undefined) state.signal = overrides.signal; +} + +function makeTerminalPane( + terminalId: string, + paneId: string, +): CreatePaneInput { + return { + id: paneId, + kind: "terminal", + titleOverride: "Workspace Run", + data: { terminalId } as TerminalPaneData, + }; +} + +function getDefinitionId( + definition: ReturnType, +): string | undefined { + if (!definition) return undefined; + return definition.source === "terminal-preset" + ? definition.presetId + : definition.projectId; +} + +interface UseV2WorkspaceRunArgs { + store: StoreApi>; + launcher: TerminalLauncher; + matchedPresets: V2TerminalPresetRow[]; + resolvePresetCommands: (preset: V2TerminalPresetRow) => Promise; +} + +export function useV2WorkspaceRun({ + store, + launcher, + matchedPresets, + resolvePresetCommands, +}: UseV2WorkspaceRunArgs) { + const { workspace } = useWorkspace(); + const workspaceId = workspace.id; + const projectId = workspace.projectId; + const collections = useCollections(); + const [isPending, setIsPending] = useState(false); + const isStartingRef = useRef(false); + const utils = workspaceTrpc.useUtils(); + const writeInputMutation = workspaceTrpc.terminal.writeInput.useMutation(); + const killSessionMutation = workspaceTrpc.terminal.killSession.useMutation(); + const [resolvedPresetCommandsById, setResolvedPresetCommandsById] = useState< + Record + >({}); + + const { data: localWorkspaceRows = [] } = useLiveQuery( + (query) => + query + .from({ v2WorkspaceLocalState: collections.v2WorkspaceLocalState }) + .where(({ v2WorkspaceLocalState }) => + eq(v2WorkspaceLocalState.workspaceId, workspaceId), + ), + [collections, workspaceId], + ); + const localWorkspaceState = localWorkspaceRows[0] ?? null; + const workspaceRunTerminals = useMemo( + () => localWorkspaceState?.workspaceRunTerminals ?? {}, + [localWorkspaceState?.workspaceRunTerminals], + ); + + const { data: configRunDefinition } = + workspaceTrpc.config.getWorkspaceRunDefinition.useQuery({ projectId }); + + useEffect(() => { + let cancelled = false; + + async function resolveCommands() { + const entries = await Promise.all( + matchedPresets.map(async (preset) => { + try { + return { + id: preset.id, + commands: await resolvePresetCommands(preset), + }; + } catch { + return { id: preset.id, commands: preset.commands }; + } + }), + ); + if (cancelled) return; + const next: Record = {}; + for (const entry of entries) { + next[entry.id] = entry.commands; + } + setResolvedPresetCommandsById(next); + } + + void resolveCommands(); + + return () => { + cancelled = true; + }; + }, [matchedPresets, resolvePresetCommands]); + + const resolvedMatchedPresets = useMemo( + () => + matchedPresets.map((preset) => ({ + ...preset, + commands: resolvedPresetCommandsById[preset.id] ?? preset.commands, + })), + [matchedPresets, resolvedPresetCommandsById], + ); + + const definition = useMemo( + () => + selectWorkspaceRunDefinition({ + presets: resolvedMatchedPresets, + configRunCommands: configRunDefinition?.commands, + configCwd: configRunDefinition?.cwd, + projectId, + }), + [ + configRunDefinition?.commands, + configRunDefinition?.cwd, + projectId, + resolvedMatchedPresets, + ], + ); + + const runningState = useMemo( + () => + Object.values(workspaceRunTerminals) + .filter((state) => state.state === "running") + .sort((a, b) => b.startedAt - a.startedAt)[0] ?? null, + [workspaceRunTerminals], + ); + + const updateWorkspaceRunTerminals = useCallback( + (updater: (states: Record) => void) => { + if (!collections.v2WorkspaceLocalState.get(workspaceId)) return; + collections.v2WorkspaceLocalState.update(workspaceId, (draft) => { + draft.workspaceRunTerminals ??= {}; + updater(draft.workspaceRunTerminals); + }); + }, + [collections.v2WorkspaceLocalState, workspaceId], + ); + + const startWorkspaceRun = useCallback(async () => { + if (isStartingRef.current) return; + const command = buildTerminalCommand(definition?.commands); + if (!definition || !command) { + toast.error("No workspace run command configured", { + description: + "Add a run script in Project Settings or mark a preset as the workspace run.", + }); + return; + } + + isStartingRef.current = true; + setIsPending(true); + try { + const terminalId = await launcher.create({ + command, + cwd: definition.cwd, + }); + const startedAt = Date.now(); + updateWorkspaceRunTerminals((states) => { + states[terminalId] = { + terminalId, + workspaceId, + state: "running", + command, + definitionSource: definition.source, + definitionId: getDefinitionId(definition), + startedAt, + }; + }); + + const tabId = crypto.randomUUID(); + const paneId = crypto.randomUUID(); + const pane = makeTerminalPane(terminalId, paneId); + store.getState().addTab({ id: tabId, panes: [pane] }); + } catch (error) { + toast.error("Failed to run workspace command", { + description: error instanceof Error ? error.message : "Unknown error", + }); + } finally { + isStartingRef.current = false; + setIsPending(false); + } + }, [definition, launcher, store, updateWorkspaceRunTerminals, workspaceId]); + + const stopWorkspaceRun = useCallback(async () => { + if (!runningState) return; + setIsPending(true); + try { + const stopRequestedAt = Date.now(); + await writeInputMutation.mutateAsync({ + terminalId: runningState.terminalId, + workspaceId, + data: CTRL_C_INPUT, + }); + const stoppedAt = Date.now(); + updateWorkspaceRunTerminals((states) => { + const state = states[runningState.terminalId]; + if (!state || state.state !== "running") return; + state.stopRequestedAt = stopRequestedAt; + markStopped(state, stoppedAt, { state: "stopped-by-user" }); + }); + } catch (error) { + if (isTerminalGoneError(error)) { + const stoppedAt = Date.now(); + updateWorkspaceRunTerminals((states) => { + const state = states[runningState.terminalId]; + if (!state || state.state !== "running") return; + markStopped(state, stoppedAt); + }); + return; + } + + updateWorkspaceRunTerminals((states) => { + const state = states[runningState.terminalId]; + if (!state || state.state !== "running") return; + delete state.stopRequestedAt; + }); + toast.error("Failed to stop workspace run command", { + description: error instanceof Error ? error.message : "Unknown error", + }); + } finally { + setIsPending(false); + } + }, [ + runningState, + updateWorkspaceRunTerminals, + workspaceId, + writeInputMutation, + ]); + + const forceStopWorkspaceRun = useCallback(async () => { + if (!runningState) return; + setIsPending(true); + try { + await killSessionMutation.mutateAsync({ + terminalId: runningState.terminalId, + workspaceId, + }); + const stoppedAt = Date.now(); + updateWorkspaceRunTerminals((states) => { + const state = states[runningState.terminalId]; + if (!state) return; + state.stopRequestedAt ??= stoppedAt; + markStopped(state, stoppedAt, { state: "stopped-by-user" }); + }); + await utils.terminal.listSessions.invalidate({ workspaceId }); + } catch (error) { + if (isTerminalGoneError(error)) { + const stoppedAt = Date.now(); + updateWorkspaceRunTerminals((states) => { + const state = states[runningState.terminalId]; + if (!state || state.state !== "running") return; + markStopped(state, stoppedAt); + }); + await utils.terminal.listSessions.invalidate({ workspaceId }); + return; + } + + toast.error("Failed to force stop workspace run command", { + description: error instanceof Error ? error.message : "Unknown error", + }); + } finally { + setIsPending(false); + } + }, [ + killSessionMutation, + runningState, + updateWorkspaceRunTerminals, + utils, + workspaceId, + ]); + + const toggleWorkspaceRun = useCallback(async () => { + if (runningState) { + await stopWorkspaceRun(); + return; + } + await startWorkspaceRun(); + }, [runningState, startWorkspaceRun, stopWorkspaceRun]); + + useWorkspaceEvent("terminal:lifecycle", workspaceId, (payload) => { + if (payload.eventType !== "exit") return; + updateWorkspaceRunTerminals((states) => { + const state = states[payload.terminalId]; + if (!state || state.state !== "running") return; + markStopped(state, payload.occurredAt, { + exitCode: payload.exitCode, + signal: payload.signal, + }); + }); + }); + + return { + canForceStop: Boolean(runningState), + definition, + forceStopWorkspaceRun, + isPending, + isRunning: Boolean(runningState), + runningState, + toggleWorkspaceRun, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index 9c8c239b133..8056475181b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -11,6 +11,7 @@ import { useWorkspace } from "../providers/WorkspaceProvider"; import { AddTabMenu } from "./components/AddTabMenu"; import { V2NotificationStatusIndicator } from "./components/V2NotificationStatusIndicator"; import { V2PresetsBar } from "./components/V2PresetsBar"; +import { V2WorkspaceRunButton } from "./components/V2WorkspaceRunButton"; import { WorkspaceEmptyState } from "./components/WorkspaceEmptyState"; import { WorkspaceSidebar } from "./components/WorkspaceSidebar"; import { useBrowserShellInteractionPassthrough } from "./hooks/useBrowserShellInteractionPassthrough"; @@ -25,6 +26,7 @@ import { renderBrowserTabIcon } from "./hooks/usePaneRegistry/components/Browser import { useV2PresetExecution } from "./hooks/useV2PresetExecution"; import { useV2TerminalLauncher } from "./hooks/useV2TerminalLauncher"; import { useV2WorkspacePaneLayout } from "./hooks/useV2WorkspacePaneLayout"; +import { useV2WorkspaceRun } from "./hooks/useV2WorkspaceRun"; import { useWorkspaceFileNavigation } from "./hooks/useWorkspaceFileNavigation"; import { useWorkspaceHotkeys } from "./hooks/useWorkspaceHotkeys"; import { useWorkspacePaneOpeners } from "./hooks/useWorkspacePaneOpeners"; @@ -89,9 +91,16 @@ function V2WorkspacePage() { const { store } = useV2WorkspacePaneLayout(); useClearActivePaneAttention({ store }); const launcher = useV2TerminalLauncher(); - const { matchedPresets, executePreset } = useV2PresetExecution({ + const { matchedPresets, executePreset, resolvePresetCommands } = + useV2PresetExecution({ + store, + launcher, + }); + const workspaceRun = useV2WorkspaceRun({ store, launcher, + matchedPresets, + resolvePresetCommands, }); useConsumeAutomationRunLink({ store, @@ -179,6 +188,21 @@ function V2WorkspacePage() { launcher, }); useHotkey("QUICK_OPEN", handleQuickOpen); + useHotkey("RUN_WORKSPACE_COMMAND", () => { + void workspaceRun.toggleWorkspaceRun(); + }); + + const workspaceRunButton = ( + + ); return ( @@ -197,17 +221,20 @@ function V2WorkspacePage() { sources={getV2NotificationSourcesForTab(tab)} /> )} - renderBelowTabBar={ - showPresetsBar - ? () => ( - - ) - : undefined + renderBelowTabBar={() => + showPresetsBar ? ( + + ) : ( +
+ {workspaceRunButton} +
+ ) } renderAddTabMenu={() => ( { - const pane = Object.values(s.panes).find( + const panes = Object.values(s.panes).filter( (p) => p.type === "terminal" && p.workspaceRun?.workspaceId === workspaceId, ); - return pane ?? null; + return ( + panes.find((pane) => pane.workspaceRun?.state === "running") ?? + panes[0] ?? + null + ); }); const isRunning = runPane?.workspaceRun?.state === "running"; @@ -114,59 +118,24 @@ export function useWorkspaceRunCommand({ try { // START: always fetch the latest config so run-script detection never // depends on stale cache state or on a query still loading in the view. - const runConfig = - await electronTrpcClient.workspaces.getResolvedRunCommands.query({ + const runDefinition = + await electronTrpcClient.workspaces.getWorkspaceRunDefinition.query({ workspaceId, }); - const command = buildTerminalCommand(runConfig.commands); + const command = buildTerminalCommand(runDefinition?.commands); if (!command) { toast.error("No workspace run command configured", { description: - "Add a run script in Project Settings to use the workspace run shortcut.", + "Add a run script in Project Settings or mark a preset as the workspace run.", }); return; } - const initialCwd = worktreePath?.trim() ? worktreePath : undefined; - - // Reuse existing run pane if available - if (runPane) { - const tabsState = useTabsStore.getState(); - const tab = tabsState.tabs.find((t) => t.id === runPane.tabId); - if (tab) { - setActiveTab(workspaceId, tab.id); - setFocusedPane(tab.id, runPane.id); - } - - setPaneWorkspaceRun( - runPane.id, - createWorkspaceRun({ - workspaceId, - state: "running", - command, - }), - ); - - try { - await launchWorkspaceRunInPane({ - paneId: runPane.id, - tabId: runPane.tabId, - command, - cwd: initialCwd, - }); - } catch (error) { - setPaneWorkspaceRunState(runPane.id, "stopped-by-exit"); - toast.error("Failed to run workspace command", { - description: - error instanceof Error ? error.message : "Unknown error", - }); - } - return; - } + const fallbackCwd = worktreePath?.trim() ? worktreePath : undefined; + const initialCwd = runDefinition?.cwd ?? fallbackCwd; - // Create new pane and persist the resolved command on the pane metadata - // before mount. Terminal lifecycle then sees the same click-time command - // snapshot that presets use, instead of waiting on a follow-up query. + // Always start in a fresh tab. Old run panes stay inspectable instead + // of having their terminal session swapped under the user. const result = addTab(workspaceId, { initialCwd }); const { tabId, paneId } = result; diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.test.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.test.ts index 489ccec4287..690e1af44ee 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.test.ts @@ -101,10 +101,12 @@ describe("healWorkspaceLocalState", () => { ...baseStored, viewedFiles: undefined, recentlyViewedFiles: undefined, + workspaceRunTerminals: undefined, }; const healed = healWorkspaceLocalState(stored); expect(healed.viewedFiles).toEqual([]); expect(healed.recentlyViewedFiles).toEqual([]); + expect(healed.workspaceRunTerminals).toEqual({}); }); it("fills missing nested sidebarState fields while preserving projectId", () => { diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts index 2e48bbca6bc..0382cf92872 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts @@ -28,6 +28,26 @@ const changesFilterSchema = z.discriminatedUnion("kind", [ export type ChangesFilter = z.infer; +const workspaceRunStateSchema = z.enum([ + "running", + "stopped-by-user", + "stopped-by-exit", +]); + +export const workspaceRunTerminalStateSchema = z.object({ + terminalId: z.string(), + workspaceId: z.string().uuid(), + state: workspaceRunStateSchema, + command: z.string(), + definitionSource: z.enum(["project-config", "terminal-preset"]), + definitionId: z.string().optional(), + startedAt: z.number(), + stoppedAt: z.number().optional(), + exitCode: z.number().optional(), + signal: z.number().optional(), + stopRequestedAt: z.number().optional(), +}); + export const workspaceLocalStateSchema = z.object({ workspaceId: z.string().uuid(), createdAt: persistedDateSchema, @@ -50,6 +70,9 @@ export const workspaceLocalStateSchema = z.object({ }), ) .default([]), + workspaceRunTerminals: z + .record(z.string(), workspaceRunTerminalStateSchema) + .default({}), }); // Defaults for fields heal can synthesize. Identity fields (workspaceId, @@ -70,6 +93,10 @@ const WORKSPACE_LOCAL_STATE_OPTIONAL_DEFAULTS = { absolutePath: string; lastAccessedAt: number; }>, + workspaceRunTerminals: {} as Record< + string, + z.infer + >, }; export const dashboardSidebarSectionSchema = z.object({ @@ -98,6 +125,7 @@ export const v2TerminalPresetSchema = z.object({ commands: z.array(z.string()).default([]), projectIds: z.array(z.string()).nullable().default(null), pinnedToBar: z.boolean().optional(), + useAsWorkspaceRun: z.boolean().optional(), applyOnWorkspaceCreated: z.boolean().optional(), applyOnNewTab: z.boolean().optional(), executionMode: v2ExecutionModeSchema.default("new-tab"), @@ -114,6 +142,10 @@ export type DashboardSidebarProjectRow = z.infer< typeof dashboardSidebarProjectSchema >; export type WorkspaceLocalStateRow = z.infer; +export type WorkspaceRunState = z.infer; +export type WorkspaceRunTerminalState = z.infer< + typeof workspaceRunTerminalStateSchema +>; export type DashboardSidebarSectionRow = z.infer< typeof dashboardSidebarSectionSchema >; @@ -214,6 +246,9 @@ export function healWorkspaceLocalState(raw: unknown): WorkspaceLocalStateRow { recentlyViewedFiles: r.recentlyViewedFiles ?? WORKSPACE_LOCAL_STATE_OPTIONAL_DEFAULTS.recentlyViewedFiles, + workspaceRunTerminals: + r.workspaceRunTerminals ?? + WORKSPACE_LOCAL_STATE_OPTIONAL_DEFAULTS.workspaceRunTerminals, sidebarState: { ...SIDEBAR_STATE_DEFAULTS, ...sidebar, diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/withReadHeal.test.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/withReadHeal.test.ts index 2a19cf39b67..0bebb2a3e47 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/withReadHeal.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/withReadHeal.test.ts @@ -200,6 +200,7 @@ describe("withReadHeal end-to-end via real localStorageCollectionOptions", () => // Optional defaults filled. expect(row?.viewedFiles).toEqual([]); expect(row?.recentlyViewedFiles).toEqual([]); + expect(row?.workspaceRunTerminals).toEqual({}); // Nested sidebarState defaults filled. expect(row?.sidebarState.activeTab).toBe("changes"); expect(row?.sidebarState.changesFilter).toEqual({ kind: "all" }); 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 b26daeee6ca..ecf85a92dc4 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 @@ -90,6 +90,7 @@ export function PresetRow({ ); const isWorkspaceCreation = !!preset.applyOnWorkspaceCreated; + const isWorkspaceRun = !!preset.useAsWorkspaceRun; const isNewTab = !!preset.applyOnNewTab; const isVisibleInBar = preset.pinnedToBar !== false; const modeValue = normalizeExecutionMode(preset.executionMode); @@ -157,6 +158,14 @@ export function PresetRow({ Workspace )} + {isWorkspaceRun && ( + + Run + + )} {isNewTab && ( { + updatePreset.mutate({ + id: presetId, + patch: { useAsWorkspaceRun: enabled }, + }); + }, + [updatePreset], + ); + const handleToggleVisibility = useCallback( (presetId: string, visible: boolean) => { updatePreset.mutate({ @@ -367,6 +377,7 @@ export function PresetsSection({ }, [editingRowIndex, handleDeleteRow, setEditingPreset]); const isWorkspaceCreation = !!editingPreset?.applyOnWorkspaceCreated; + const isWorkspaceRun = !!editingPreset?.useAsWorkspaceRun; const isNewTab = !!editingPreset?.applyOnNewTab; const hasMultipleCommands = (editingPreset?.commands.length ?? 0) > 1; const normalizedMode = normalizeExecutionMode(editingPreset?.executionMode); @@ -457,6 +468,14 @@ export function PresetsSection({ [editingPreset, handleToggleAutoApply], ); + const handleEditorWorkspaceRunToggle = useCallback( + (enabled: boolean) => { + if (!editingPreset) return; + handleToggleWorkspaceRun(editingPreset.id, enabled); + }, + [editingPreset, handleToggleWorkspaceRun], + ); + return (
@@ -522,8 +541,10 @@ export function PresetsSection({ onCommandsBlur={handleEditorCommandsBlur} onModeChange={handleEditorModeChange} onToggleAutoApply={handleEditorAutoApplyToggle} + onToggleWorkspaceRun={handleEditorWorkspaceRunToggle} modeValue={modeValue} hasMultipleCommands={hasMultipleCommands} + isWorkspaceRun={isWorkspaceRun} isWorkspaceCreation={isWorkspaceCreation} isNewTab={isNewTab} /> diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetsSection/components/PresetEditorDialog/PresetEditorDialog.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetsSection/components/PresetEditorDialog/PresetEditorDialog.tsx index 376d434cf13..51bd9ab55f0 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetsSection/components/PresetEditorDialog/PresetEditorDialog.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetsSection/components/PresetEditorDialog/PresetEditorDialog.tsx @@ -63,8 +63,10 @@ interface PresetEditorDialogProps { onCommandsBlur: () => void; onModeChange: (mode: ExecutionMode) => void; onToggleAutoApply: (field: AutoApplyField, enabled: boolean) => void; + onToggleWorkspaceRun: (enabled: boolean) => void; modeValue: ExecutionMode; hasMultipleCommands: boolean; + isWorkspaceRun: boolean; isWorkspaceCreation: boolean; isNewTab: boolean; } @@ -183,8 +185,10 @@ export function PresetEditorDialog({ onCommandsBlur, onModeChange, onToggleAutoApply, + onToggleWorkspaceRun, modeValue, hasMultipleCommands, + isWorkspaceRun, isWorkspaceCreation, isNewTab, }: PresetEditorDialogProps) { @@ -461,6 +465,20 @@ export function PresetEditorDialog({ )} + +
+ +
+
+ { @@ -234,6 +235,7 @@ export function V2PresetsSection({ commands: input.commands, projectIds: input.projectIds ?? null, pinnedToBar: input.pinnedToBar, + useAsWorkspaceRun: input.useAsWorkspaceRun, executionMode: input.executionMode ?? "new-tab", tabOrder: maxTabOrder + 1, createdAt: new Date(), @@ -457,6 +459,13 @@ export function V2PresetsSection({ [updateV2Preset], ); + const handleToggleWorkspaceRun = useCallback( + (presetId: string, enabled: boolean) => { + updateV2Preset(presetId, { useAsWorkspaceRun: enabled }); + }, + [updateV2Preset], + ); + const handleToggleVisibility = useCallback( (presetId: string, visible: boolean) => { updateV2Preset(presetId, { pinnedToBar: visible }); @@ -494,6 +503,7 @@ export function V2PresetsSection({ }, [editingRowIndex, handleDeleteRow, setEditingPreset]); const isWorkspaceCreation = !!editingPreset?.applyOnWorkspaceCreated; + const isWorkspaceRun = !!editingPreset?.useAsWorkspaceRun; const isNewTab = !!editingPreset?.applyOnNewTab; const hasMultipleCommands = (editingPreset?.commands.length ?? 0) > 1; const normalizedMode = normalizeExecutionMode(editingPreset?.executionMode); @@ -578,6 +588,14 @@ export function V2PresetsSection({ [editingPreset, handleToggleAutoApply], ); + const handleEditorWorkspaceRunToggle = useCallback( + (enabled: boolean) => { + if (!editingPreset) return; + handleToggleWorkspaceRun(editingPreset.id, enabled); + }, + [editingPreset, handleToggleWorkspaceRun], + ); + return (
@@ -639,8 +657,10 @@ export function V2PresetsSection({ onCommandsBlur={handleEditorCommandsBlur} onModeChange={handleEditorModeChange} onToggleAutoApply={handleEditorAutoApplyToggle} + onToggleWorkspaceRun={handleEditorWorkspaceRunToggle} modeValue={modeValue} hasMultipleCommands={hasMultipleCommands} + isWorkspaceRun={isWorkspaceRun} isWorkspaceCreation={isWorkspaceCreation} isNewTab={isNewTab} /> diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts index b47bd1e6d90..be9ab8c2947 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts @@ -1003,12 +1003,13 @@ export const SETTINGS_ITEMS: SettingsItem[] = [ id: SETTING_ITEM_ID.PROJECT_SCRIPTS, section: "project", title: "Scripts", - description: "Setup and teardown scripts for workspaces", + description: "Setup, teardown, and run scripts for workspaces", keywords: [ "project", "scripts", "setup", "teardown", + "run", "bash", "shell", "automation", diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx index b73abb2729b..2ceda6e05e8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx @@ -85,7 +85,7 @@ export function V2ProjectSettings({ projectId }: V2ProjectSettingsProps) { {activeHostUrl && ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/V2ScriptsEditor/V2ScriptsEditor.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/V2ScriptsEditor/V2ScriptsEditor.tsx index 25fe6d4fb09..fa43b791dc4 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/V2ScriptsEditor/V2ScriptsEditor.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/V2ScriptsEditor/V2ScriptsEditor.tsx @@ -21,10 +21,11 @@ interface V2ScriptsEditorProps { interface ParsedConfig { setup: string; teardown: string; + run: string; } function parseConfigContent(content: string | null): ParsedConfig { - if (!content) return { setup: "", teardown: "" }; + if (!content) return { setup: "", teardown: "", run: "" }; try { const parsed = JSON.parse(content); const setup = Array.isArray(parsed?.setup) @@ -35,12 +36,16 @@ function parseConfigContent(content: string | null): ParsedConfig { (s: unknown): s is string => typeof s === "string", ) : []; + const run = Array.isArray(parsed?.run) + ? parsed.run.filter((s: unknown): s is string => typeof s === "string") + : []; return { setup: setup.join("\n"), teardown: teardown.join("\n"), + run: run.join("\n"), }; } catch { - return { setup: "", teardown: "" }; + return { setup: "", teardown: "", run: "" }; } } @@ -81,11 +86,17 @@ export function V2ScriptsEditor({ const [setupValue, setSetupValue] = useState(""); const [teardownValue, setTeardownValue] = useState(""); + const [runValue, setRunValue] = useState(""); const [saveStatus, setSaveStatus] = useState("idle"); - const focusedRef = useRef<"setup" | "teardown" | null>(null); - const lastSavedRef = useRef<{ setup: string[]; teardown: string[] }>({ + const focusedRef = useRef<"setup" | "teardown" | "run" | null>(null); + const lastSavedRef = useRef<{ + setup: string[]; + teardown: string[]; + run: string[]; + }>({ setup: [], teardown: [], + run: [], }); const savedTimerRef = useRef(null); @@ -95,9 +106,11 @@ export function V2ScriptsEditor({ const parsed = parseConfigContent(configData?.content ?? null); setSetupValue(parsed.setup); setTeardownValue(parsed.teardown); + setRunValue(parsed.run); lastSavedRef.current = { setup: toCommandsArray(parsed.setup), teardown: toCommandsArray(parsed.teardown), + run: toCommandsArray(parsed.run), }; }, [configData?.content]); @@ -112,6 +125,7 @@ export function V2ScriptsEditor({ projectId: string; setup: string[]; teardown: string[]; + run: string[]; }) => getHostServiceClientByUrl(hostUrl).config.updateConfig.mutate(input), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: configQueryKey }); @@ -119,10 +133,11 @@ export function V2ScriptsEditor({ }); const flushSave = useCallback( - async (next: { setup: string[]; teardown: string[] }) => { + async (next: { setup: string[]; teardown: string[]; run: string[] }) => { if ( arraysEqual(next.setup, lastSavedRef.current.setup) && - arraysEqual(next.teardown, lastSavedRef.current.teardown) + arraysEqual(next.teardown, lastSavedRef.current.teardown) && + arraysEqual(next.run, lastSavedRef.current.run) ) { return; } @@ -150,7 +165,7 @@ export function V2ScriptsEditor({ ); const handleBlur = useCallback( - async (field: "setup" | "teardown") => { + async (field: "setup" | "teardown" | "run") => { focusedRef.current = null; const trimmedSetup = setupValue @@ -163,18 +178,25 @@ export function V2ScriptsEditor({ .map((line) => line.trim()) .join("\n") .replace(/^\n+|\n+$/g, ""); + const trimmedRun = runValue + .split("\n") + .map((line) => line.trim()) + .join("\n") + .replace(/^\n+|\n+$/g, ""); if (trimmedSetup !== setupValue) setSetupValue(trimmedSetup); if (trimmedTeardown !== teardownValue) setTeardownValue(trimmedTeardown); + if (trimmedRun !== runValue) setRunValue(trimmedRun); await flushSave({ setup: toCommandsArray(field === "setup" ? trimmedSetup : setupValue), teardown: toCommandsArray( field === "teardown" ? trimmedTeardown : teardownValue, ), + run: toCommandsArray(field === "run" ? trimmedRun : runValue), }); }, - [flushSave, setupValue, teardownValue], + [flushSave, runValue, setupValue, teardownValue], ); if (isLoading) { @@ -218,6 +240,7 @@ export function V2ScriptsEditor({ Setup Teardown + Run handleBlur("teardown")} /> + + { + focusedRef.current = "run"; + }} + onBlur={() => handleBlur("run")} + /> +
); } interface ScriptFieldProps { - field: "setup" | "teardown"; + field: "setup" | "teardown" | "run"; description: string; placeholder: string; value: string; diff --git a/apps/desktop/src/shared/types/config.ts b/apps/desktop/src/shared/types/config.ts index 6de0f6942e7..bd7474bd208 100644 --- a/apps/desktop/src/shared/types/config.ts +++ b/apps/desktop/src/shared/types/config.ts @@ -2,6 +2,7 @@ export interface SetupConfig { setup?: string[]; teardown?: string[]; run?: string[]; + cwd?: string; } export interface LocalScriptMerge { diff --git a/apps/desktop/src/shared/workspace-run-definition.test.ts b/apps/desktop/src/shared/workspace-run-definition.test.ts new file mode 100644 index 00000000000..117d1eb6120 --- /dev/null +++ b/apps/desktop/src/shared/workspace-run-definition.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "bun:test"; +import { selectWorkspaceRunDefinition } from "./workspace-run-definition"; + +describe("selectWorkspaceRunDefinition", () => { + it("prefers a project-targeted workspace-run preset over config", () => { + const definition = selectWorkspaceRunDefinition({ + projectId: "project-a", + configRunCommands: ["bun dev"], + presets: [ + { + id: "preset-a", + name: "Project dev", + commands: ["pnpm dev"], + projectIds: ["project-a"], + useAsWorkspaceRun: true, + }, + ], + }); + + expect(definition).toEqual({ + source: "terminal-preset", + presetId: "preset-a", + name: "Project dev", + commands: ["pnpm dev"], + }); + }); + + it("uses config before a global workspace-run preset", () => { + const definition = selectWorkspaceRunDefinition({ + projectId: "project-a", + configRunCommands: ["bun dev"], + presets: [ + { + id: "preset-global", + name: "Global dev", + commands: ["npm run dev"], + projectIds: null, + useAsWorkspaceRun: true, + }, + ], + }); + + expect(definition).toEqual({ + source: "project-config", + projectId: "project-a", + commands: ["bun dev"], + }); + }); + + it("preserves config cwd", () => { + const definition = selectWorkspaceRunDefinition({ + projectId: "project-a", + configRunCommands: ["bun dev"], + configCwd: "apps/web", + presets: [], + }); + + expect(definition).toEqual({ + source: "project-config", + projectId: "project-a", + commands: ["bun dev"], + cwd: "apps/web", + }); + }); + + it("falls back to a global workspace-run preset when config is empty", () => { + const definition = selectWorkspaceRunDefinition({ + projectId: "project-a", + configRunCommands: [" "], + presets: [ + { + id: "preset-global", + name: "Global dev", + commands: ["npm run dev"], + useAsWorkspaceRun: true, + }, + ], + }); + + expect(definition).toEqual({ + source: "terminal-preset", + presetId: "preset-global", + name: "Global dev", + commands: ["npm run dev"], + }); + }); +}); diff --git a/apps/desktop/src/shared/workspace-run-definition.ts b/apps/desktop/src/shared/workspace-run-definition.ts new file mode 100644 index 00000000000..16c1e44d045 --- /dev/null +++ b/apps/desktop/src/shared/workspace-run-definition.ts @@ -0,0 +1,108 @@ +import { + filterMatchingPresetsForProject, + isProjectTargetedPreset, +} from "./preset-project-targeting"; + +export type WorkspaceRunDefinition = + | { + source: "project-config"; + projectId: string; + commands: string[]; + cwd?: string; + } + | { + source: "terminal-preset"; + presetId: string; + name: string; + commands: string[]; + cwd?: string; + }; + +export interface WorkspaceRunPresetLike { + id: string; + name: string; + commands: string[]; + cwd?: string; + projectIds?: string[] | null; + useAsWorkspaceRun?: boolean; +} + +function nonEmptyCommands(commands: readonly string[] | null | undefined) { + return (commands ?? []).filter((command) => command.trim().length > 0); +} + +function normalizeCwd(cwd: string | undefined): string | undefined { + const trimmed = cwd?.trim(); + return trimmed ? trimmed : undefined; +} + +export function configRunToWorkspaceRun({ + projectId, + commands, + cwd, +}: { + projectId: string; + commands: readonly string[] | null | undefined; + cwd?: string; +}): WorkspaceRunDefinition | null { + const resolvedCommands = nonEmptyCommands(commands); + if (resolvedCommands.length === 0) return null; + return { + source: "project-config", + projectId, + commands: resolvedCommands, + cwd: normalizeCwd(cwd), + }; +} + +export function presetToWorkspaceRun( + preset: WorkspaceRunPresetLike, +): WorkspaceRunDefinition | null { + if (!preset.useAsWorkspaceRun) return null; + const commands = nonEmptyCommands(preset.commands); + if (commands.length === 0) return null; + return { + source: "terminal-preset", + presetId: preset.id, + name: preset.name, + commands, + cwd: normalizeCwd(preset.cwd), + }; +} + +export function selectWorkspaceRunDefinition({ + presets, + configRunCommands, + projectId, + configCwd, +}: { + presets: readonly WorkspaceRunPresetLike[]; + configRunCommands?: readonly string[] | null; + projectId: string; + configCwd?: string; +}): WorkspaceRunDefinition | null { + const matchingPresets = filterMatchingPresetsForProject(presets, projectId); + const targetedPresetRun = matchingPresets + .filter(isProjectTargetedPreset) + .map(presetToWorkspaceRun) + .find((definition): definition is WorkspaceRunDefinition => + Boolean(definition), + ); + if (targetedPresetRun) return targetedPresetRun; + + const configRun = configRunToWorkspaceRun({ + projectId, + commands: configRunCommands, + cwd: configCwd, + }); + if (configRun) return configRun; + + return ( + matchingPresets + .filter((preset) => !isProjectTargetedPreset(preset)) + .map(presetToWorkspaceRun) + .find((definition): definition is WorkspaceRunDefinition => + Boolean(definition), + ) ?? null + ); +} diff --git a/packages/host-service/src/runtime/setup/config.test.ts b/packages/host-service/src/runtime/setup/config.test.ts index 90042a2ac7b..172c58b2951 100644 --- a/packages/host-service/src/runtime/setup/config.test.ts +++ b/packages/host-service/src/runtime/setup/config.test.ts @@ -120,6 +120,26 @@ describe("loadSetupConfig", () => { expect(result).toBeNull(); }); + it("rejects blank cwd values", () => { + writeRepoConfig(sandbox.repoPath, { + cwd: " ", + run: ["bun dev"], + }); + + const result = load(); + expect(result).toBeNull(); + }); + + it("normalizes configured cwd", () => { + writeRepoConfig(sandbox.repoPath, { + cwd: " packages/web ", + run: ["bun dev"], + }); + + const result = load(); + expect(result?.cwd).toBe("packages/web"); + }); + it("user override only sets keys it explicitly defines", () => { writeRepoConfig(sandbox.repoPath, { setup: ["bun install"], @@ -295,8 +315,8 @@ describe("hasConfiguredScripts", () => { }); it("returns true when only run is set (so the card hides for run-only)", () => { - // v2 doesn't expose run, but the loader still considers it "configured" - // so the sidebar CTA hides for projects that came over from v1. + // The setup CTA should still hide when a project only configured the + // workspace Run button. expect(hasConfiguredScripts({ run: ["bun dev"] })).toBe(true); }); }); diff --git a/packages/host-service/src/runtime/setup/config.ts b/packages/host-service/src/runtime/setup/config.ts index 74e20cb5eb0..59f3a8c3648 100644 --- a/packages/host-service/src/runtime/setup/config.ts +++ b/packages/host-service/src/runtime/setup/config.ts @@ -12,6 +12,7 @@ export interface SetupConfig { setup?: string[]; teardown?: string[]; run?: string[]; + cwd?: string; } interface LocalScriptMerge { @@ -55,6 +56,15 @@ function validateSetupConfig( } const obj = parsed as Record; const result: SetupConfig = {}; + if (obj.cwd !== undefined) { + if (typeof obj.cwd !== "string" || obj.cwd.trim().length === 0) { + console.error( + `Invalid setup config at ${source}: 'cwd' must be a non-empty string`, + ); + return null; + } + result.cwd = obj.cwd.trim(); + } for (const key of SCRIPT_KEYS) { const value = obj[key]; if (value === undefined) continue; @@ -128,6 +138,7 @@ function mergeBaseConfigs( setup: override.setup ?? base.setup, teardown: override.teardown ?? base.teardown, run: override.run ?? base.run, + cwd: override.cwd ?? base.cwd, }; } diff --git a/packages/host-service/src/terminal/terminal.ts b/packages/host-service/src/terminal/terminal.ts index 049c172d7d3..b8e9eab9c54 100644 --- a/packages/host-service/src/terminal/terminal.ts +++ b/packages/host-service/src/terminal/terminal.ts @@ -1,4 +1,5 @@ import { existsSync } from "node:fs"; +import { isAbsolute, join } from "node:path"; import { StringDecoder } from "node:string_decoder"; import type { NodeWebSocket } from "@hono/node-ws"; import { @@ -364,6 +365,30 @@ export function listTerminalSessions( })); } +export function writeInputToSession({ + terminalId, + workspaceId, + data, +}: { + terminalId: string; + workspaceId: string; + data: string; +}): { success: true } | { error: string } { + const session = sessions.get(terminalId); + if (!session) { + return { error: "Terminal session not found" }; + } + if (session.workspaceId !== workspaceId) { + return { error: "Terminal session does not belong to this workspace" }; + } + if (session.exited) { + return { error: "Terminal session has exited" }; + } + + session.pty.write(data); + return { success: true }; +} + function sendMessage( socket: { send: (data: string) => void; readyState: number }, message: TerminalServerMessage, @@ -608,6 +633,7 @@ interface CreateTerminalSessionOptions { eventBus?: EventBus; /** Command to run after the shell is ready. Queued behind shellReadyPromise. */ initialCommand?: string; + cwd?: string; /** Hidden sessions are process-internal and should not appear in user pickers. */ listed?: boolean; cols?: number; @@ -623,6 +649,22 @@ interface CreateTerminalSessionOptions { replayOnAdoption?: boolean; } +function resolveTerminalCwd( + cwdOverride: string | undefined, + worktreePath: string, +): string { + if (!cwdOverride) return worktreePath; + if (isAbsolute(cwdOverride)) { + return existsSync(cwdOverride) ? cwdOverride : worktreePath; + } + + const relativePath = cwdOverride.startsWith("./") + ? cwdOverride.slice(2) + : cwdOverride; + const resolvedPath = join(worktreePath, relativePath); + return existsSync(resolvedPath) ? resolvedPath : worktreePath; +} + export async function createTerminalSessionInternal({ terminalId, workspaceId, @@ -630,6 +672,7 @@ export async function createTerminalSessionInternal({ db, eventBus, initialCommand, + cwd: cwdOverride, listed = true, cols: requestedCols, rows: requestedRows, @@ -660,7 +703,7 @@ export async function createTerminalSessionInternal({ rootPath = project.repoPath; } - const cwd = workspace.worktreePath; + const cwd = resolveTerminalCwd(cwdOverride, workspace.worktreePath); const cols = normalizeTerminalDimension( requestedCols, MIN_TERMINAL_COLS, @@ -872,11 +915,12 @@ export async function createTerminalSessionInternal({ session.exited = true; session.exitCode = code ?? 0; session.exitSignal = signal ?? 0; + const occurredAt = Date.now(); portManager.unregisterSession(terminalId); db.update(terminalSessions) - .set({ status: "exited", endedAt: Date.now() }) + .set({ status: "exited", endedAt: occurredAt }) .where(eq(terminalSessions.id, terminalId)) .run(); @@ -892,7 +936,7 @@ export async function createTerminalSessionInternal({ eventType: "exit", exitCode: session.exitCode, signal: session.exitSignal, - occurredAt: Date.now(), + occurredAt, }); }, }, @@ -917,6 +961,7 @@ export function registerWorkspaceTerminalRoute({ workspaceId: string; themeType?: string; initialCommand?: string; + cwd?: string; cols?: number; rows?: number; }>(); @@ -932,6 +977,7 @@ export function registerWorkspaceTerminalRoute({ db, eventBus, initialCommand: body.initialCommand, + cwd: body.cwd, cols: body.cols, rows: body.rows, }); diff --git a/packages/host-service/src/trpc/router/config/config.test.ts b/packages/host-service/src/trpc/router/config/config.test.ts index 242251fe68c..d3ed642317f 100644 --- a/packages/host-service/src/trpc/router/config/config.test.ts +++ b/packages/host-service/src/trpc/router/config/config.test.ts @@ -132,6 +132,35 @@ describe("configRouter", () => { }); }); + it("updates run when provided and preserves setup/teardown when omitted", async () => { + const caller = createCaller(sandbox.repoPath); + const dir = join(sandbox.repoPath, ".superset"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "config.json"), + JSON.stringify({ + setup: ["bun install"], + teardown: ["docker compose down"], + run: ["old dev"], + }), + "utf-8", + ); + + await caller.updateConfig({ + projectId: PROJECT_ID, + run: ["bun dev"], + }); + + const parsed = JSON.parse( + readFileSync(join(dir, "config.json"), "utf-8"), + ); + expect(parsed).toEqual({ + setup: ["bun install"], + teardown: ["docker compose down"], + run: ["bun dev"], + }); + }); + it("preserves unrelated top-level keys (forward compatibility)", async () => { const caller = createCaller(sandbox.repoPath); const dir = join(sandbox.repoPath, ".superset"); @@ -230,8 +259,6 @@ describe("configRouter", () => { }); it("returns false when only the run key is set", async () => { - // updateConfig doesn't accept run, but a v1-imported project might - // already have one — the card should hide for those too. const caller = createCaller(sandbox.repoPath); const dir = join(sandbox.repoPath, ".superset"); mkdirSync(dir, { recursive: true }); @@ -246,4 +273,51 @@ describe("configRouter", () => { ); }); }); + + describe("getWorkspaceRunDefinition", () => { + it("returns null when run is not configured", async () => { + const caller = createCaller(sandbox.repoPath); + expect( + await caller.getWorkspaceRunDefinition({ projectId: PROJECT_ID }), + ).toBeNull(); + }); + + it("returns non-empty run commands from resolved config", async () => { + const caller = createCaller(sandbox.repoPath); + await caller.updateConfig({ + projectId: PROJECT_ID, + setup: [], + teardown: [], + run: ["", "bun dev", " "], + }); + + expect( + await caller.getWorkspaceRunDefinition({ projectId: PROJECT_ID }), + ).toEqual({ + source: "project-config", + projectId: PROJECT_ID, + commands: ["bun dev"], + }); + }); + + it("preserves cwd from resolved config", async () => { + const caller = createCaller(sandbox.repoPath); + const dir = join(sandbox.repoPath, ".superset"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "config.json"), + JSON.stringify({ run: ["bun dev"], cwd: "apps/web" }), + "utf-8", + ); + + expect( + await caller.getWorkspaceRunDefinition({ projectId: PROJECT_ID }), + ).toEqual({ + source: "project-config", + projectId: PROJECT_ID, + commands: ["bun dev"], + cwd: "apps/web", + }); + }); + }); }); diff --git a/packages/host-service/src/trpc/router/config/config.ts b/packages/host-service/src/trpc/router/config/config.ts index 4f266e5de9d..05b1ac1bfcf 100644 --- a/packages/host-service/src/trpc/router/config/config.ts +++ b/packages/host-service/src/trpc/router/config/config.ts @@ -79,14 +79,16 @@ export const configRouter = router({ /** * Write setup/teardown to the project's config.json, preserving any other - * existing top-level keys (notably `run`, which v2 doesn't expose yet). + * existing top-level keys. Omitted script keys are preserved so narrow + * editors can update one script without clobbering another. */ updateConfig: protectedProcedure .input( z.object({ projectId: z.string().uuid(), - setup: stringArray, - teardown: stringArray, + setup: stringArray.optional(), + teardown: stringArray.optional(), + run: stringArray.optional(), }), ) .mutation(({ ctx, input }) => { @@ -108,8 +110,9 @@ export const configRouter = router({ const merged: SetupConfig & Record = { ...existing, - setup: input.setup, - teardown: input.teardown, + ...(input.setup !== undefined && { setup: input.setup }), + ...(input.teardown !== undefined && { teardown: input.teardown }), + ...(input.run !== undefined && { run: input.run }), }; try { @@ -122,4 +125,24 @@ export const configRouter = router({ } return { success: true as const }; }), + + getWorkspaceRunDefinition: protectedProcedure + .input(projectIdInput) + .query(({ ctx, input }) => { + const project = requireProject(ctx, input.projectId); + const config = loadSetupConfig({ + repoPath: project.repoPath, + projectId: project.id, + }); + const commands = (config?.run ?? []).filter( + (command) => command.trim().length > 0, + ); + if (commands.length === 0) return null; + return { + source: "project-config" as const, + projectId: project.id, + commands, + ...(config?.cwd?.trim() && { cwd: config.cwd.trim() }), + }; + }), }); diff --git a/packages/host-service/src/trpc/router/terminal/terminal.ts b/packages/host-service/src/trpc/router/terminal/terminal.ts index 9879659d453..99ff5e7046f 100644 --- a/packages/host-service/src/trpc/router/terminal/terminal.ts +++ b/packages/host-service/src/trpc/router/terminal/terminal.ts @@ -8,6 +8,7 @@ import { disposeSession, listTerminalSessions, parseThemeType, + writeInputToSession, } from "../../../terminal/terminal"; import type { HostServiceContext } from "../../../types"; import { protectedProcedure, router } from "../../index"; @@ -16,6 +17,7 @@ const createSessionInputSchema = z.object({ workspaceId: z.string(), terminalId: z.string().optional(), initialCommand: z.string().trim().min(1).optional(), + cwd: z.string().optional(), themeType: z.string().optional(), cols: z.number().int().positive().optional(), rows: z.number().int().positive().optional(), @@ -36,6 +38,7 @@ async function createTerminalSessionFromInput({ db: ctx.db, eventBus: ctx.eventBus, initialCommand: input.initialCommand, + cwd: input.cwd, cols: input.cols, rows: input.rows, }); @@ -115,6 +118,25 @@ export const terminalRouter = router({ }), })), + writeInput: protectedProcedure + .input( + z.object({ + terminalId: z.string(), + workspaceId: z.string(), + data: z.string(), + }), + ) + .mutation(({ input }) => { + const result = writeInputToSession(input); + if ("error" in result) { + throw new TRPCError({ + code: "NOT_FOUND", + message: result.error, + }); + } + return { success: true as const }; + }), + killSession: protectedProcedure .input( z.object({ diff --git a/packages/local-db/src/schema/zod.ts b/packages/local-db/src/schema/zod.ts index 95be79c18ea..0a2b200fd6b 100644 --- a/packages/local-db/src/schema/zod.ts +++ b/packages/local-db/src/schema/zod.ts @@ -110,6 +110,7 @@ export const terminalPresetSchema = z.object({ commands: z.array(z.string()), projectIds: z.array(z.string()).nullable().optional(), pinnedToBar: z.boolean().optional(), + useAsWorkspaceRun: z.boolean().optional(), applyOnWorkspaceCreated: z.boolean().optional(), applyOnNewTab: z.boolean().optional(), executionMode: z.enum(EXECUTION_MODES).optional(), diff --git a/plans/done/20260509-run-script-presets-design.md b/plans/done/20260509-run-script-presets-design.md new file mode 100644 index 00000000000..e7555645902 --- /dev/null +++ b/plans/done/20260509-run-script-presets-design.md @@ -0,0 +1,48 @@ +# Workspace Run Definitions With Preset Backing + +Status: implemented + +## What Shipped + +- Added a shared workspace-run resolver that selects one run definition from + project-targeted presets, `.superset/config.json` `run`, or global presets. +- Added `useAsWorkspaceRun` to terminal preset schemas and settings UI. +- Kept `.superset/config.json` `run` as the simple repo-owned script path. +- Updated the v1 Run button to consume workspace-run definitions while keeping + v1-style stop semantics. +- Added v2 workspace Run button support, backed by terminal-id keyed local run + state. +- Added host-service `config.getWorkspaceRunDefinition` and + `terminal.writeInput` so v2 can resolve project config and send Ctrl-C + without depending on a mounted terminal pane. +- Added a Run tab to the v2 project scripts editor. + +## Final Semantics + +The Run button is intentionally a small bridge between project scripts and +terminal presets, not a generalized command framework. + +Resolution precedence: + +1. project-targeted preset with `useAsWorkspaceRun: true`; +2. project config `run`; +3. global preset with `useAsWorkspaceRun: true`; +4. none. + +Starting a run creates a fresh terminal session and a fresh terminal viewer so +old output stays inspectable. Stopping sends Ctrl-C and immediately marks the +run stopped, matching v1 behavior and avoiding a sticky intermediate +`stopping` state. Force Stop kills the terminal session when a run is still +recorded as running. + +V2 run metadata lives in workspace local state keyed by `terminalId`. Pane data +stays as `{ terminalId }`; terminal UI derives the run badge/status by looking +up that id. + +## Deferred + +- Config-backed run rows in the general preset table. +- Repo-owned preset arrays or read-only virtual preset rows. +- Database-backed run metadata in host-service. +- Readiness, health, or custom lifecycle hooks from run scripts. +- A generalized command origin/source model beyond workspace run.