diff --git a/apps/docs/content/docs/en/keyboard-shortcuts/index.mdx b/apps/docs/content/docs/en/keyboard-shortcuts/index.mdx index ad9ece3b50f..c5459d6369b 100644 --- a/apps/docs/content/docs/en/keyboard-shortcuts/index.mdx +++ b/apps/docs/content/docs/en/keyboard-shortcuts/index.mdx @@ -31,10 +31,12 @@ Speed up your workflow building with these keyboard shortcuts and mouse controls | `Mod` + `Z` | Undo | | `Mod` + `Shift` + `Z` | Redo | | `Mod` + `C` | Copy selected blocks | +| `Mod` + `X` | Cut selected blocks | | `Mod` + `V` | Paste blocks | | `Delete` or `Backspace` | Delete selected blocks or edges | | `Shift` + `L` | Auto-layout canvas | | `Mod` + `Shift` + `F` | Fit to view | +| `Mod` + `F` | Open workflow search and replace | | `Mod` + `Shift` + `Enter` | Accept Copilot changes | ## Panel Navigation @@ -43,7 +45,7 @@ These shortcuts switch between panel tabs on the right side of the canvas. | Shortcut | Action | |----------|--------| -| `Mod` + `F` | Focus Toolbar search | +| `Mod` + `Alt` + `F` | Focus Toolbar search | ## Global Navigation diff --git a/apps/realtime/src/database/operations.ts b/apps/realtime/src/database/operations.ts index 279b56a9b03..14fa8639eaf 100644 --- a/apps/realtime/src/database/operations.ts +++ b/apps/realtime/src/database/operations.ts @@ -8,6 +8,7 @@ import { EDGE_OPERATIONS, EDGES_OPERATIONS, OPERATION_TARGETS, + SUBBLOCK_OPERATIONS, SUBFLOW_OPERATIONS, VARIABLE_OPERATIONS, WORKFLOW_OPERATIONS, @@ -15,6 +16,7 @@ import { import { getActiveWorkflowContext } from '@sim/workflow-authz' import { loadWorkflowFromNormalizedTablesRaw } from '@sim/workflow-persistence/load' import { mergeSubBlockValues } from '@sim/workflow-persistence/subblocks' +import { isWorkflowBlockProtected } from '@sim/workflow-types/workflow' import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm' import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' @@ -46,26 +48,6 @@ interface DbBlockRef { data: unknown } -/** - * Checks if a block is protected (locked or inside a locked ancestor). - * Works with raw DB records. - */ -function isDbBlockProtected(blockId: string, blocksById: Record): boolean { - const block = blocksById[blockId] - if (!block) return false - if (block.locked) return true - const visited = new Set() - let parentId = (block.data as Record | null)?.parentId as string | undefined - while (parentId && !visited.has(parentId)) { - visited.add(parentId) - if (blocksById[parentId]?.locked) return true - parentId = (blocksById[parentId]?.data as Record | null)?.parentId as - | string - | undefined - } - return false -} - /** * Finds all descendant block IDs of a container (recursive). * Works with raw DB block arrays. @@ -251,6 +233,9 @@ export async function persistWorkflowOperation(workflowId: string, operation: an case OPERATION_TARGETS.SUBFLOW: await handleSubflowOperationTx(tx, workflowId, op, payload) break + case OPERATION_TARGETS.SUBBLOCK: + await handleSubblockOperationTx(tx, workflowId, op, payload) + break case OPERATION_TARGETS.VARIABLE: await handleVariableOperationTx(tx, workflowId, op, payload) break @@ -876,7 +861,7 @@ async function handleBlocksOperationTx( ) // Filter out protected blocks from deletion request - const deletableIds = ids.filter((id) => !isDbBlockProtected(id, blocksById)) + const deletableIds = ids.filter((id) => !isWorkflowBlockProtected(id, blocksById)) if (deletableIds.length === 0) { logger.info('All requested blocks are protected, skipping deletion') return @@ -991,14 +976,14 @@ async function handleBlocksOperationTx( // Collect all blocks to toggle including descendants of containers for (const id of blockIds) { const block = blocksById[id] - if (!block || isDbBlockProtected(id, blocksById)) continue + if (!block || isWorkflowBlockProtected(id, blocksById)) continue blocksToToggle.add(id) // If it's a loop or parallel, also include all non-locked descendants if (block.type === 'loop' || block.type === 'parallel') { for (const descId of findDbDescendants(id, allBlocks)) { - if (!isDbBlockProtected(descId, blocksById)) { + if (!isWorkflowBlockProtected(descId, blocksById)) { blocksToToggle.add(descId) } } @@ -1053,7 +1038,7 @@ async function handleBlocksOperationTx( // Filter to only toggle handles on unprotected blocks const blocksToToggle = blockIds.filter( - (id) => blocksById[id] && !isDbBlockProtected(id, blocksById) + (id) => blocksById[id] && !isWorkflowBlockProtected(id, blocksById) ) if (blocksToToggle.length === 0) { logger.info('All requested blocks are protected, skipping handles toggle') @@ -1165,13 +1150,13 @@ async function handleBlocksOperationTx( if (!id) continue // Skip protected blocks (locked or inside locked container) - if (isDbBlockProtected(id, blocksById)) { + if (isWorkflowBlockProtected(id, blocksById)) { logger.info(`Skipping block ${id} parent update - block is protected`) continue } // Skip if trying to move into a locked container (or any of its ancestors) - if (parentId && isDbBlockProtected(parentId, blocksById)) { + if (parentId && isWorkflowBlockProtected(parentId, blocksById)) { logger.info(`Skipping block ${id} parent update - target parent ${parentId} is protected`) continue } @@ -1295,7 +1280,7 @@ async function handleEdgeOperationTx(tx: any, workflowId: string, operation: str } } - if (isDbBlockProtected(payload.target, blocksById)) { + if (isWorkflowBlockProtected(payload.target, blocksById)) { logger.info(`Skipping edge add - target block is protected`) break } @@ -1383,7 +1368,7 @@ async function handleEdgeOperationTx(tx: any, workflowId: string, operation: str } } - if (isDbBlockProtected(edgeToRemove.targetBlockId, blocksById)) { + if (isWorkflowBlockProtected(edgeToRemove.targetBlockId, blocksById)) { logger.info(`Skipping edge remove - target block is protected`) break } @@ -1494,7 +1479,7 @@ async function handleEdgesOperationTx( } const safeEdgeIds = edgesToRemove - .filter((e: EdgeToRemove) => !isDbBlockProtected(e.targetBlockId, blocksById)) + .filter((e: EdgeToRemove) => !isWorkflowBlockProtected(e.targetBlockId, blocksById)) .map((e: EdgeToRemove) => e.id) if (safeEdgeIds.length === 0) { @@ -1581,7 +1566,7 @@ async function handleEdgesOperationTx( // Filter edges - only add edges where target block is not protected const safeEdges = (edges as Array>).filter( - (e) => !isDbBlockProtected(e.target as string, blocksById) + (e) => !isWorkflowBlockProtected(e.target as string, blocksById) ) if (safeEdges.length === 0) { @@ -1734,6 +1719,86 @@ async function handleSubflowOperationTx( } } +function valuesEqual(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right) +} + +// Subblock operations - targeted value updates without replacing workflow state +async function handleSubblockOperationTx( + tx: any, + workflowId: string, + operation: string, + payload: any +) { + switch (operation) { + case SUBBLOCK_OPERATIONS.BATCH_UPDATE: { + const updates = payload.updates + if (!Array.isArray(updates) || updates.length === 0) { + return + } + + const allBlocks = await tx + .select({ + id: workflowBlocks.id, + subBlocks: workflowBlocks.subBlocks, + locked: workflowBlocks.locked, + data: workflowBlocks.data, + }) + .from(workflowBlocks) + .where(eq(workflowBlocks.workflowId, workflowId)) + + type SubblockUpdateBlockRecord = (typeof allBlocks)[number] + const blocksById: Record = Object.fromEntries( + allBlocks.map((block: SubblockUpdateBlockRecord) => [block.id, block]) + ) + + for (const update of updates) { + const { blockId, subblockId, value, expectedValue } = update + if (!blockId || !subblockId) { + throw new Error('Missing required fields for subblock batch update') + } + + const block = blocksById[blockId] + if (!block) { + throw new Error(`Block ${blockId} not found`) + } + + if (isWorkflowBlockProtected(blockId, blocksById)) { + throw new Error(`Block ${blockId} is locked or inside a locked container`) + } + + const subBlocks = { ...((block.subBlocks as Record) || {}) } + const currentSubBlock = subBlocks[subblockId] + const currentValue = currentSubBlock?.value + if (expectedValue !== undefined && !valuesEqual(currentValue, expectedValue)) { + throw new Error(`Subblock ${blockId}.${subblockId} changed since replacement was planned`) + } + + subBlocks[subblockId] = currentSubBlock + ? { ...currentSubBlock, value } + : { id: subblockId, type: 'unknown', value } + + await tx + .update(workflowBlocks) + .set({ + subBlocks, + updatedAt: new Date(), + }) + .where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId))) + + blocksById[blockId] = { ...block, subBlocks } + } + + logger.debug(`Batch updated ${updates.length} subblocks for workflow ${workflowId}`) + break + } + + default: + logger.warn(`Unknown subblock operation: ${operation}`) + throw new Error(`Unsupported subblock operation: ${operation}`) + } +} + // Variable operations - updates workflow.variables JSON field async function handleVariableOperationTx( tx: any, diff --git a/apps/realtime/src/handlers/subblocks.ts b/apps/realtime/src/handlers/subblocks.ts index b3be99e7457..4650f8487cc 100644 --- a/apps/realtime/src/handlers/subblocks.ts +++ b/apps/realtime/src/handlers/subblocks.ts @@ -3,6 +3,7 @@ import { workflow, workflowBlocks } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { SUBBLOCK_OPERATIONS } from '@sim/realtime-protocol/constants' import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz' +import { isWorkflowBlockProtected } from '@sim/workflow-types/workflow' import { and, eq } from 'drizzle-orm' import type { AuthenticatedSocket } from '@/middleware/auth' import { checkRolePermission } from '@/middleware/permissions' @@ -273,45 +274,33 @@ async function flushSubblockUpdate( let updateSuccessful = false let blockLocked = false await db.transaction(async (tx) => { - const [block] = await tx + const allBlocks = await tx .select({ + id: workflowBlocks.id, subBlocks: workflowBlocks.subBlocks, locked: workflowBlocks.locked, data: workflowBlocks.data, }) .from(workflowBlocks) - .where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId))) - .limit(1) + .where(eq(workflowBlocks.workflowId, workflowId)) + type SubblockUpdateBlockRecord = (typeof allBlocks)[number] + const blocksById: Record = Object.fromEntries( + allBlocks.map((block: SubblockUpdateBlockRecord) => [block.id, block]) + ) + const block = blocksById[blockId] if (!block) { return } - // Check if block is locked directly - if (block.locked) { - logger.info(`Skipping subblock update - block ${blockId} is locked`) + if (isWorkflowBlockProtected(blockId, blocksById)) { + logger.info( + `Skipping subblock update - block ${blockId} is locked or inside a locked container` + ) blockLocked = true return } - // Check if block is inside a locked parent container - const parentId = (block.data as Record | null)?.parentId as - | string - | undefined - if (parentId) { - const [parentBlock] = await tx - .select({ locked: workflowBlocks.locked }) - .from(workflowBlocks) - .where(and(eq(workflowBlocks.id, parentId), eq(workflowBlocks.workflowId, workflowId))) - .limit(1) - - if (parentBlock?.locked) { - logger.info(`Skipping subblock update - parent ${parentId} is locked`) - blockLocked = true - return - } - } - const subBlocks = (block.subBlocks as any) || {} if (!subBlocks[subblockId]) { subBlocks[subblockId] = { id: subblockId, type: 'unknown', value } diff --git a/apps/realtime/src/middleware/permissions.test.ts b/apps/realtime/src/middleware/permissions.test.ts index 2d8cd12999c..0aa9cada905 100644 --- a/apps/realtime/src/middleware/permissions.test.ts +++ b/apps/realtime/src/middleware/permissions.test.ts @@ -51,6 +51,11 @@ describe('checkRolePermission', () => { const result = checkRolePermission('admin', 'replace-state') expectPermissionAllowed(result) }) + + it('should allow subblock-batch-update operation', () => { + const result = checkRolePermission('admin', 'subblock-batch-update') + expectPermissionAllowed(result) + }) }) describe('write role', () => { @@ -77,6 +82,11 @@ describe('checkRolePermission', () => { const result = checkRolePermission('write', 'update-position') expectPermissionAllowed(result) }) + + it('should allow subblock-batch-update operation', () => { + const result = checkRolePermission('write', 'subblock-batch-update') + expectPermissionAllowed(result) + }) }) describe('read role', () => { @@ -111,6 +121,11 @@ describe('checkRolePermission', () => { expectPermissionDenied(result, 'read') }) + it('should deny subblock-batch-update operation for read role', () => { + const result = checkRolePermission('read', 'subblock-batch-update') + expectPermissionDenied(result, 'read') + }) + it('should deny toggle-enabled operation for read role', () => { const result = checkRolePermission('read', 'toggle-enabled') expectPermissionDenied(result, 'read') diff --git a/apps/realtime/src/middleware/permissions.ts b/apps/realtime/src/middleware/permissions.ts index dcc893b1478..661f4d52d44 100644 --- a/apps/realtime/src/middleware/permissions.ts +++ b/apps/realtime/src/middleware/permissions.ts @@ -46,6 +46,7 @@ const WRITE_OPERATIONS: string[] = [ SUBFLOW_OPERATIONS.UPDATE, // Subblock operations SUBBLOCK_OPERATIONS.UPDATE, + SUBBLOCK_OPERATIONS.BATCH_UPDATE, // Variable operations VARIABLE_OPERATIONS.UPDATE, // Workflow operations diff --git a/apps/sim/app/workspace/[workspaceId]/utils/commands-utils.ts b/apps/sim/app/workspace/[workspaceId]/utils/commands-utils.ts index 8bdbeac2577..bb237c157c9 100644 --- a/apps/sim/app/workspace/[workspaceId]/utils/commands-utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/utils/commands-utils.ts @@ -14,6 +14,7 @@ export type CommandId = // | 'goto-templates' | 'goto-logs' | 'open-search' + | 'open-workflow-search-replace' | 'run-workflow' | 'clear-terminal-console' | 'focus-toolbar-search' @@ -79,6 +80,11 @@ export const COMMAND_DEFINITIONS: Record = { shortcut: 'Mod+K', allowInEditable: true, }, + 'open-workflow-search-replace': { + id: 'open-workflow-search-replace', + shortcut: 'Mod+F', + allowInEditable: true, + }, 'run-workflow': { id: 'run-workflow', shortcut: 'Mod+Enter', @@ -91,7 +97,7 @@ export const COMMAND_DEFINITIONS: Record = { }, 'focus-toolbar-search': { id: 'focus-toolbar-search', - shortcut: 'Mod+F', + shortcut: 'Mod+Alt+F', allowInEditable: false, }, 'clear-notifications': { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu/block-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu/block-menu.tsx index 79e8464bf49..f77a76394c7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu/block-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu/block-menu.tsx @@ -35,6 +35,7 @@ export interface BlockMenuProps { onClose: () => void selectedBlocks: BlockInfo[] onCopy: () => void + onCut: () => void onPaste: () => void onDuplicate: () => void onDelete: () => void @@ -74,6 +75,7 @@ export function BlockMenu({ onClose, selectedBlocks, onCopy, + onCut, onPaste, onDuplicate, onDelete, @@ -162,6 +164,17 @@ export function BlockMenu({ Copy ⌘C + { + onCut() + onClose() + }} + > + Cut + ⌘X + void onFitToView: () => void onOpenLogs: () => void + onOpenSearchReplace: () => void onToggleVariables: () => void onToggleChat: () => void onToggleWorkflowLock?: () => void @@ -59,6 +60,7 @@ export function CanvasMenu({ onAutoLayout, onFitToView, onOpenLogs, + onOpenSearchReplace, onToggleVariables, onToggleChat, onToggleWorkflowLock, @@ -114,9 +116,6 @@ export function CanvasMenu({ Redo ⌘⇧Z - - {/* Edit and creation actions */} - Paste ⌘V + + {/* Edit and creation actions */} + + { + onOpenSearchReplace() + onClose() + }} + > + Search and replace + ⌘F + { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx index 1103e3d7fe6..8e0b53a195f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx @@ -1050,13 +1050,12 @@ export function DeployModal({ interface StatusBadgeProps { isWarning: boolean - isSyncing?: boolean } -function StatusBadge({ isWarning, isSyncing = false }: StatusBadgeProps) { - const label = isSyncing ? 'Syncing changes' : isWarning ? 'Update deployment' : 'Live' +function StatusBadge({ isWarning }: StatusBadgeProps) { + const label = isWarning ? 'Update deployment' : 'Live' return ( - + {label} ) @@ -1111,7 +1110,7 @@ function GeneralFooter({ const isDeployBlocked = deployReadiness.isBlocked || isDeploymentSettling || isSubmitting || isUndeploying const blockedMessage = - deployReadiness.isBlocked && !isDeploymentSettling && !isSubmitting && !isUndeploying + deployReadiness.isBlocked && !deployReadiness.isSyncing && !isSubmitting && !isUndeploying ? deployReadiness.tooltip : null const deployActionLoading = isSubmitting || isDeploymentSettling @@ -1133,7 +1132,7 @@ function GeneralFooter({ return (
- + {blockedMessage && (
{blockedMessage}
)} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx index ec630e08781..cc5a65825f0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx @@ -1,7 +1,7 @@ 'use client' import { useState } from 'react' -import { Button, Loader, Tooltip } from '@/components/emcn' +import { Button, Tooltip } from '@/components/emcn' import { DeployModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal' import { useChangeDetection, @@ -65,12 +65,16 @@ export function Deploy({ isDeploying || !canDeploy || isEmpty || - isDeploymentSettling || - (!isDeployed && deployReadiness.isBlocked) + (!isDeployed && deployReadiness.isBlocked && !deployReadiness.isSyncing) const onDeployClick = async () => { if (disabled || !canDeploy || !activeWorkflowId) return + if (isDeploymentSettling) { + setIsModalOpen(true) + return + } + const result = await handleDeployClick() if (result.shouldOpenModal) { setIsModalOpen(true) @@ -106,9 +110,6 @@ export function Deploy({ } const getButtonLabel = () => { - if (isDeployed && (changeDetected || isDeploymentSettling)) { - return 'Update' - } if (changeDetected) { return 'Update' } @@ -126,18 +127,11 @@ export function Deploy({ diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx index a354f177e08..db50e5b3200 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx @@ -72,6 +72,8 @@ const FOLDER_OVERRIDES: SelectorOverrides = { }, } +const WORKFLOW_SEARCH_CURRENT_MATCH_CLASS = 'rounded-md bg-orange-400 px-1 py-0.5' + /** * Interface for wand control handlers exposed by sub-block inputs */ @@ -103,6 +105,7 @@ interface SubBlockProps { labelSuffix?: React.ReactNode /** Provides sibling values for dependency resolution in non-preview contexts (e.g. tool-input) */ dependencyContext?: Record + isSearchHighlighted?: boolean } /** @@ -229,6 +232,7 @@ const renderLabel = ( onCopy: () => void }, labelSuffix?: React.ReactNode, + isSearchHighlighted?: boolean, externalLink?: { show: boolean onClick: () => void @@ -248,7 +252,11 @@ const renderLabel = ( return (