Skip to content
4 changes: 3 additions & 1 deletion apps/docs/content/docs/en/keyboard-shortcuts/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
125 changes: 95 additions & 30 deletions apps/realtime/src/database/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ import {
EDGE_OPERATIONS,
EDGES_OPERATIONS,
OPERATION_TARGETS,
SUBBLOCK_OPERATIONS,
SUBFLOW_OPERATIONS,
VARIABLE_OPERATIONS,
WORKFLOW_OPERATIONS,
} from '@sim/realtime-protocol/constants'
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'
Expand Down Expand Up @@ -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<string, DbBlockRef>): boolean {
const block = blocksById[blockId]
if (!block) return false
if (block.locked) return true
const visited = new Set<string>()
let parentId = (block.data as Record<string, unknown> | 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<string, unknown> | null)?.parentId as
| string
| undefined
}
return false
}

/**
* Finds all descendant block IDs of a container (recursive).
* Works with raw DB block arrays.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -1581,7 +1566,7 @@ async function handleEdgesOperationTx(

// Filter edges - only add edges where target block is not protected
const safeEdges = (edges as Array<Record<string, unknown>>).filter(
(e) => !isDbBlockProtected(e.target as string, blocksById)
(e) => !isWorkflowBlockProtected(e.target as string, blocksById)
)

if (safeEdges.length === 0) {
Expand Down Expand Up @@ -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<string, SubblockUpdateBlockRecord> = 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<string, any>) || {}) }
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,
Comment thread
icecrasher321 marked this conversation as resolved.
Expand Down
37 changes: 13 additions & 24 deletions apps/realtime/src/handlers/subblocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<string, SubblockUpdateBlockRecord> = 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<string, unknown> | 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 }
Expand Down
15 changes: 15 additions & 0 deletions apps/realtime/src/middleware/permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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')
Expand Down
1 change: 1 addition & 0 deletions apps/realtime/src/middleware/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion apps/sim/app/workspace/[workspaceId]/utils/commands-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -79,6 +80,11 @@ export const COMMAND_DEFINITIONS: Record<CommandId, CommandDefinition> = {
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',
Expand All @@ -91,7 +97,7 @@ export const COMMAND_DEFINITIONS: Record<CommandId, CommandDefinition> = {
},
'focus-toolbar-search': {
id: 'focus-toolbar-search',
shortcut: 'Mod+F',
shortcut: 'Mod+Alt+F',
allowInEditable: false,
},
'clear-notifications': {
Expand Down
Loading
Loading